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        "adf-unsupported" => {
2704            // Round-trips an unsupported inline node emitted by
2705            // `render_non_text_inline_body`: the full node JSON rides in the
2706            // `json` attribute (the inline mirror of the block-level
2707            // ```adf-unsupported``` fence reader, ADR-0029 / issue #1117).
2708            // Deserialize it straight back into the original node; a malformed
2709            // or missing payload falls through to plain text, exactly like the
2710            // block reader degrading to a normal code block.
2711            let json = d.attrs.as_ref().and_then(|a| a.get("json"))?;
2712            serde_json::from_str::<AdfNode>(json).ok()?
2713        }
2714        _ => return None, // unknown directive — fall through to plain text
2715    };
2716
2717    Some((node, d.end_pos))
2718}
2719
2720/// Tries to parse a bare URL (`http://` or `https://`) starting at position `i`.
2721/// Scans forward until whitespace, `)`, `]`, or end of string.
2722fn try_parse_bare_url(text: &str, i: usize) -> Option<(usize, &str)> {
2723    let rest = &text[i..];
2724    if !rest.starts_with("http://") && !rest.starts_with("https://") {
2725        return None;
2726    }
2727    // URL extends to the next whitespace or delimiter
2728    let end = rest
2729        .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '>')
2730        .unwrap_or(rest.len());
2731    // Strip trailing punctuation that's likely not part of the URL
2732    let url = rest[..end].trim_end_matches(['.', ',', ';', '!', '?']);
2733    if url.len() <= "https://".len() {
2734        return None; // too short to be a real URL
2735    }
2736    Some((i + url.len(), url))
2737}
2738
2739/// Tries to parse an emoji shortcode `:name:` starting at position `i`.
2740/// The name must match `[a-zA-Z0-9_+-]+`.
2741fn try_parse_emoji_shortcode(text: &str, i: usize) -> Option<(usize, &str)> {
2742    let rest = &text[i..];
2743    if !rest.starts_with(':') {
2744        return None;
2745    }
2746    let after = &rest[1..];
2747    let name_end =
2748        after.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '+' && c != '-')?;
2749    if name_end == 0 {
2750        return None;
2751    }
2752    if after.as_bytes().get(name_end) != Some(&b':') {
2753        return None;
2754    }
2755    let name = &after[..name_end];
2756    Some((i + 1 + name_end + 1, name))
2757}
2758
2759/// Parses an emoji shortcode that has already been matched, then checks for
2760/// trailing `{id="..." text="..."}` attributes to preserve round-trip fidelity.
2761fn parse_emoji_with_attrs(text: &str, shortcode_end: usize, short_name: &str) -> (usize, AdfNode) {
2762    // Issue #576: An emoji with a combined shortName like `:slightly_smiling_face::bow:`
2763    // is emitted as `:slightly_smiling_face::bow:{shortName="..." ...}`. Extend the
2764    // match through any adjacent `:name:` shortcodes so that a trailing directive
2765    // attaches to the whole chain as a single emoji, using the directive's shortName.
2766    let mut chain_end = shortcode_end;
2767    while let Some((next_end, _)) = try_parse_emoji_shortcode(text, chain_end) {
2768        chain_end = next_end;
2769    }
2770    if chain_end > shortcode_end {
2771        if let Some((attr_end, attrs)) = parse_attrs(text, chain_end) {
2772            return (attr_end, build_emoji_node(&attrs, short_name));
2773        }
2774    }
2775
2776    if let Some((attr_end, attrs)) = parse_attrs(text, shortcode_end) {
2777        (attr_end, build_emoji_node(&attrs, short_name))
2778    } else {
2779        (shortcode_end, AdfNode::emoji(&format!(":{short_name}:")))
2780    }
2781}
2782
2783/// Builds an emoji `AdfNode` from parsed directive attrs, falling back to
2784/// the matched shortcode name when `shortName` is absent from the directive.
2785fn build_emoji_node(attrs: &Attrs, short_name: &str) -> AdfNode {
2786    let resolved_name = attrs
2787        .get("shortName")
2788        .map_or_else(|| format!(":{short_name}:"), str::to_string);
2789    let mut emoji_attrs = serde_json::json!({ "shortName": resolved_name });
2790    if let Some(id) = attrs.get("id") {
2791        emoji_attrs["id"] = serde_json::Value::String(id.to_string());
2792    }
2793    if let Some(t) = attrs.get("text") {
2794        emoji_attrs["text"] = serde_json::Value::String(t.to_string());
2795    }
2796    if let Some(lid) = attrs.get("localId") {
2797        emoji_attrs["localId"] = serde_json::Value::String(lid.to_string());
2798    }
2799    AdfNode {
2800        node_type: "emoji".to_string(),
2801        attrs: Some(emoji_attrs),
2802        content: None,
2803        text: None,
2804        marks: None,
2805        local_id: None,
2806        parameters: None,
2807    }
2808}
2809
2810/// Finds the closing `)` that matches the opening `(` at position `open`,
2811/// counting nested parentheses so that URLs containing `(` and `)` are
2812/// handled correctly.  Returns the index of the matching `)` relative to
2813/// the start of `s`, or `None` if no match is found.
2814fn find_closing_paren(s: &str, open: usize) -> Option<usize> {
2815    let mut depth: usize = 0;
2816    for (j, ch) in s[open..].char_indices() {
2817        match ch {
2818            '(' => depth += 1,
2819            ')' => {
2820                depth -= 1;
2821                if depth == 0 {
2822                    return Some(open + j);
2823                }
2824            }
2825            _ => {}
2826        }
2827    }
2828    None
2829}
2830
2831/// Tries to parse [text](url) starting at position `i`.
2832///
2833/// Uses bracket depth counting to find the matching `]`, so that `[` characters
2834/// inside the text (e.g. `[Task] some text ([Link](url))`) don't cause a false
2835/// match on an earlier `](`.
2836fn try_parse_link(text: &str, i: usize) -> Option<(usize, &str, &str)> {
2837    let rest = &text[i..];
2838    if !rest.starts_with('[') {
2839        return None;
2840    }
2841
2842    // Find the matching ] by counting bracket depth, skipping escaped brackets
2843    let mut depth: usize = 0;
2844    let mut text_end = None;
2845    let bytes = rest.as_bytes();
2846    for (j, ch) in rest.char_indices() {
2847        match ch {
2848            '\\' if j + 1 < bytes.len() && (bytes[j + 1] == b'[' || bytes[j + 1] == b']') => {
2849                // Skip backslash-escaped bracket (issue #493)
2850            }
2851            '[' if j == 0 || bytes[j - 1] != b'\\' => depth += 1,
2852            ']' if j == 0 || bytes[j - 1] != b'\\' => {
2853                depth -= 1;
2854                if depth == 0 {
2855                    text_end = Some(j);
2856                    break;
2857                }
2858            }
2859            _ => {}
2860        }
2861    }
2862
2863    let text_end = text_end?;
2864    let link_text = &rest[1..text_end];
2865    // Must be immediately followed by (
2866    let after_bracket = &rest[text_end + 1..];
2867    if !after_bracket.starts_with('(') {
2868        return None;
2869    }
2870    let url_start = text_end + 1; // index of the '('
2871    let url_end = find_closing_paren(rest, url_start)?;
2872    let href = &rest[url_start + 1..url_end];
2873
2874    Some((i + url_end + 1, link_text, href))
2875}
2876
2877// ── ADF → Markdown ──────────────────────────────────────────────────
2878
2879/// Options for ADF-to-markdown rendering.
2880#[derive(Debug, Clone, Default)]
2881pub struct RenderOptions {
2882    /// When true, omit `localId` attributes from directive output.
2883    pub strip_local_ids: bool,
2884}
2885
2886/// Converts an ADF document to a markdown string.
2887pub fn adf_to_markdown(doc: &AdfDocument) -> Result<String> {
2888    adf_to_markdown_with_options(doc, &RenderOptions::default())
2889}
2890
2891/// Converts an ADF document to a markdown string with options.
2892pub fn adf_to_markdown_with_options(doc: &AdfDocument, opts: &RenderOptions) -> Result<String> {
2893    let mut output = String::new();
2894
2895    for (i, node) in doc.content.iter().enumerate() {
2896        if i > 0 {
2897            output.push('\n');
2898        }
2899        render_block_node(node, &mut output, opts);
2900    }
2901
2902    Ok(output)
2903}
2904
2905/// Flattens an ADF document to plain text by concatenating all `text` nodes.
2906///
2907/// Block boundaries (paragraph, heading, list item, table cell, etc.) are
2908/// separated by a single space so a multi-paragraph anchor selection matches
2909/// when the user copies the text without trailing/leading whitespace.
2910///
2911/// Used by [`crate::atlassian::confluence_api::ConfluenceApi::resolve_anchor`]
2912/// to count occurrences of an inline-comment anchor on the live page body.
2913#[must_use]
2914pub fn adf_to_plain_text(doc: &AdfDocument) -> String {
2915    let mut out = String::new();
2916    for node in &doc.content {
2917        collect_plain_text(node, &mut out);
2918        if !out.is_empty() && !out.ends_with(' ') {
2919            out.push(' ');
2920        }
2921    }
2922    out.truncate(out.trim_end().len());
2923    out
2924}
2925
2926fn collect_plain_text(node: &AdfNode, out: &mut String) {
2927    if let Some(text) = &node.text {
2928        out.push_str(text);
2929    }
2930    if let Some(children) = &node.content {
2931        for child in children {
2932            collect_plain_text(child, out);
2933        }
2934    }
2935}
2936
2937/// Pushes a `localId=<value>` entry to an attribute parts vec,
2938/// unless `opts.strip_local_ids` is set or the value is a placeholder.
2939/// Copies `localId` from parsed directive attrs to an ADF node's attrs if present.
2940fn pass_through_local_id(dir_attrs: &Option<crate::atlassian::attrs::Attrs>, node: &mut AdfNode) {
2941    if let Some(ref attrs) = dir_attrs {
2942        if let Some(local_id) = attrs.get("localId") {
2943            if let Some(ref mut node_attrs) = node.attrs {
2944                node_attrs["localId"] = serde_json::Value::String(local_id.to_string());
2945            } else {
2946                node.attrs = Some(serde_json::json!({"localId": local_id}));
2947            }
2948        }
2949    }
2950}
2951
2952/// Copies `localId` from directive attrs to the node's top-level `local_id` field,
2953/// and parses `params` JSON from directive attrs into the node's `parameters` field.
2954fn pass_through_expand_params(
2955    dir_attrs: &Option<crate::atlassian::attrs::Attrs>,
2956    node: &mut AdfNode,
2957) {
2958    if let Some(ref attrs) = dir_attrs {
2959        if let Some(local_id) = attrs.get("localId") {
2960            node.local_id = Some(local_id.to_string());
2961        }
2962        if let Some(params_str) = attrs.get("params") {
2963            if let Ok(params) = serde_json::from_str(params_str) {
2964                node.parameters = Some(params);
2965            }
2966        }
2967    }
2968}
2969
2970// listItem localId is emitted as trailing inline attrs on the item line
2971// (e.g., `- item text {localId=...}`) and parsed back by extracting
2972// trailing attrs from the list item text. This avoids the block-attrs
2973// promotion issue where {localId=...} on a separate line would be
2974// applied to the parent list node.
2975
2976/// Extracts trailing `{localId=... paraLocalId=...}` from list item text.
2977/// Returns the text without the trailing attrs, the listItem localId, and
2978/// the paragraph localId if found.
2979fn extract_trailing_local_id(text: &str) -> (&str, Option<String>, Option<String>) {
2980    let trimmed = text.trim_end();
2981    if !trimmed.ends_with('}') {
2982        return (text, None, None);
2983    }
2984    // Find the opening brace.  Only match a standalone `{…}` block that is
2985    // preceded by whitespace (or is at the start of the string).  A `{` that
2986    // immediately follows `]` is part of an inline directive (e.g.
2987    // `:mention[text]{id=… localId=…}`) and must NOT be consumed here.
2988    if let Some(brace_pos) = trimmed.rfind('{') {
2989        if brace_pos > 0 && !trimmed.as_bytes()[brace_pos - 1].is_ascii_whitespace() {
2990            return (text, None, None);
2991        }
2992        let attr_str = &trimmed[brace_pos..];
2993        if let Some((_, attrs)) = parse_attrs(attr_str, 0) {
2994            let local_id = attrs.get("localId").map(str::to_string);
2995            let para_local_id = attrs.get("paraLocalId").map(str::to_string);
2996            if local_id.is_some() || para_local_id.is_some() {
2997                let before = trimmed[..brace_pos]
2998                    .strip_suffix(' ')
2999                    .unwrap_or(&trimmed[..brace_pos]);
3000                return (before, local_id, para_local_id);
3001            }
3002        }
3003    }
3004    (text, None, None)
3005}
3006
3007/// Creates a `listItem` node, optionally with a `localId` attribute
3008/// and a `paraLocalId` on its first paragraph child.
3009/// Parses the first line of a list item and any indented sub-content into
3010/// an `AdfNode::list_item`.  When the first line is a code fence opener
3011/// (`` ``` ``), the line is folded into the sub-content so the block-level
3012/// code fence parser handles it correctly (issue #511).
3013///
3014/// `depth` is the nesting depth for parsers spawned on the sub-content —
3015/// callers pass their own depth plus one.
3016fn parse_list_item_first_line(
3017    item_text: &str,
3018    sub_lines: Vec<String>,
3019    local_id: Option<String>,
3020    para_local_id: Option<String>,
3021    depth: usize,
3022) -> Result<AdfNode> {
3023    if item_text.starts_with("```") {
3024        // Treat the code fence opener + indented body as block content.
3025        let mut all_lines = vec![item_text.to_string()];
3026        all_lines.extend(sub_lines);
3027        let combined = all_lines.join("\n");
3028        let nested = MarkdownParser::with_depth(&combined, depth).parse_blocks()?;
3029        Ok(list_item_with_local_id(nested, local_id, para_local_id))
3030    } else if let Some(media) = try_parse_media_single_from_line(item_text) {
3031        // Block-level image (issue #430).
3032        if sub_lines.is_empty() {
3033            Ok(list_item_with_local_id(
3034                vec![media],
3035                local_id,
3036                para_local_id,
3037            ))
3038        } else {
3039            let sub_text = sub_lines.join("\n");
3040            let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3041            let mut content = vec![media];
3042            content.append(&mut nested);
3043            Ok(list_item_with_local_id(content, local_id, para_local_id))
3044        }
3045    } else {
3046        let first_node = AdfNode::paragraph(parse_inline(item_text));
3047        if sub_lines.is_empty() {
3048            Ok(list_item_with_local_id(
3049                vec![first_node],
3050                local_id,
3051                para_local_id,
3052            ))
3053        } else {
3054            let sub_text = sub_lines.join("\n");
3055            let mut nested = MarkdownParser::with_depth(&sub_text, depth).parse_blocks()?;
3056            let mut content = vec![first_node];
3057            content.append(&mut nested);
3058            Ok(list_item_with_local_id(content, local_id, para_local_id))
3059        }
3060    }
3061}
3062
3063fn list_item_with_local_id(
3064    mut content: Vec<AdfNode>,
3065    local_id: Option<String>,
3066    para_local_id: Option<String>,
3067) -> AdfNode {
3068    if let Some(id) = &para_local_id {
3069        if let Some(first) = content.first_mut() {
3070            if first.node_type == "paragraph" {
3071                let node_attrs = first.attrs.get_or_insert_with(|| serde_json::json!({}));
3072                node_attrs["localId"] = serde_json::Value::String(id.clone());
3073            }
3074        }
3075    }
3076    let mut item = AdfNode::list_item(content);
3077    if let Some(id) = local_id {
3078        item.attrs = Some(serde_json::json!({"localId": id}));
3079    }
3080    item
3081}
3082
3083fn maybe_push_local_id(attrs: &serde_json::Value, parts: &mut Vec<String>, opts: &RenderOptions) {
3084    if opts.strip_local_ids {
3085        return;
3086    }
3087    if let Some(local_id) = attrs.get("localId").and_then(serde_json::Value::as_str) {
3088        if !local_id.is_empty() && local_id != "00000000-0000-0000-0000-000000000000" {
3089            parts.push(format_kv("localId", local_id));
3090        }
3091    }
3092}
3093
3094/// Renders a sequence of block nodes with blank-line separators between them.
3095fn render_block_children(children: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3096    for (i, child) in children.iter().enumerate() {
3097        if i > 0 {
3098            output.push('\n');
3099        }
3100        render_block_node(child, output, opts);
3101    }
3102}
3103
3104/// Formats a float as an integer string when it has no fractional part,
3105/// otherwise as a regular float string.
3106fn fmt_f64_attr(v: f64) -> String {
3107    if v.fract() == 0.0 {
3108        format!("{}", v as i64)
3109    } else {
3110        v.to_string()
3111    }
3112}
3113
3114/// Parses a numeric attribute value string into a JSON number value that
3115/// preserves the original integer/float distinction. Returns `None` if the
3116/// string cannot be parsed as a number.
3117///
3118/// Strings without a `.` or exponent are parsed as integers (so `"800"` stays
3119/// `800`, not `800.0`); strings with a decimal point are parsed as floats.
3120fn parse_numeric_attr(s: &str) -> Option<serde_json::Value> {
3121    if s.contains('.') || s.contains('e') || s.contains('E') {
3122        s.parse::<f64>().ok().map(serde_json::Value::from)
3123    } else {
3124        s.parse::<i64>().ok().map(serde_json::Value::from)
3125    }
3126}
3127
3128/// Formats a JSON numeric value as a markdown attribute string, preserving
3129/// whether the source was stored as an integer or a float.
3130///
3131/// Returns `None` if `v` is not a number. Integer values emit as `800`;
3132/// floating-point values emit as `800.0` (or `66.66` for non-integer floats),
3133/// so that a subsequent [`parse_numeric_attr`] round-trip recovers the same
3134/// JSON type.
3135fn fmt_numeric_attr(v: &serde_json::Value) -> Option<String> {
3136    if let Some(n) = v.as_i64() {
3137        return Some(n.to_string());
3138    }
3139    if let Some(n) = v.as_u64() {
3140        return Some(n.to_string());
3141    }
3142    if let Some(n) = v.as_f64() {
3143        if n.fract() == 0.0 && n.is_finite() {
3144            return Some(format!("{n:.1}"));
3145        }
3146        return Some(n.to_string());
3147    }
3148    None
3149}
3150
3151/// Renders a block-level ADF node to markdown.
3152fn render_block_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3153    match node.node_type.as_str() {
3154        "paragraph" => {
3155            let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3156            // Build directive attr string for localId when using ::paragraph form
3157            let dir_attrs = {
3158                let mut parts = Vec::new();
3159                if let Some(ref attrs) = node.attrs {
3160                    maybe_push_local_id(attrs, &mut parts, opts);
3161                }
3162                if parts.is_empty() {
3163                    String::new()
3164                } else {
3165                    format!("{{{}}}", parts.join(" "))
3166                }
3167            };
3168            if is_empty {
3169                output.push_str(&format!("::paragraph{dir_attrs}\n"));
3170            } else {
3171                // Render to a buffer first to check if content is whitespace-only
3172                let mut buf = String::new();
3173                render_inline_content(node, &mut buf, opts);
3174                if buf.trim().is_empty() && !buf.is_empty() {
3175                    // Whitespace-only content (e.g. NBSP) would be lost as a plain
3176                    // line — use the ::paragraph[content]{attrs} directive form
3177                    output.push_str(&format!("::paragraph[{buf}]{dir_attrs}\n"));
3178                } else {
3179                    // Escape a leading list-marker pattern so paragraph
3180                    // text is not re-parsed as a list item (issue #402).
3181                    // Indent continuation lines produced by hardBreaks so
3182                    // they are not re-parsed as list items (issue #455).
3183                    let mut is_first_line = true;
3184                    for line in buf.split('\n') {
3185                        if is_first_line {
3186                            if is_list_start(line) {
3187                                output.push_str(&escape_list_marker(line));
3188                            } else {
3189                                output.push_str(line);
3190                            }
3191                            is_first_line = false;
3192                        } else {
3193                            output.push('\n');
3194                            if !line.is_empty() {
3195                                output.push_str("  ");
3196                            }
3197                            output.push_str(line);
3198                        }
3199                    }
3200                    output.push('\n');
3201                }
3202            }
3203        }
3204        "heading" => {
3205            let level = node
3206                .attrs
3207                .as_ref()
3208                .and_then(|a| a.get("level"))
3209                .and_then(serde_json::Value::as_u64)
3210                .unwrap_or(1);
3211            for _ in 0..level {
3212                output.push('#');
3213            }
3214            output.push(' ');
3215            let mut buf = String::new();
3216            render_inline_content(node, &mut buf, opts);
3217            // Indent continuation lines produced by hardBreaks so they stay
3218            // within the heading when re-parsed (issue #433).
3219            let mut is_first_line = true;
3220            for line in buf.split('\n') {
3221                if is_first_line {
3222                    output.push_str(line);
3223                    is_first_line = false;
3224                } else {
3225                    output.push('\n');
3226                    if !line.is_empty() {
3227                        output.push_str("  ");
3228                    }
3229                    output.push_str(line);
3230                }
3231            }
3232            output.push('\n');
3233        }
3234        "codeBlock" => {
3235            let language_value = node.attrs.as_ref().and_then(|a| a.get("language"));
3236            let language = language_value
3237                .and_then(serde_json::Value::as_str)
3238                .unwrap_or("");
3239            output.push_str("```");
3240            if language.is_empty() && language_value.is_some() {
3241                // Explicit empty language attr: encode as ```"" to distinguish
3242                // from a codeBlock with no attrs at all (plain ```).
3243                output.push_str("\"\"");
3244            } else {
3245                output.push_str(language);
3246            }
3247            output.push('\n');
3248            if let Some(ref content) = node.content {
3249                for child in content {
3250                    if let Some(ref text) = child.text {
3251                        output.push_str(text);
3252                    }
3253                }
3254            }
3255            output.push_str("\n```\n");
3256        }
3257        "blockquote" => {
3258            if let Some(ref content) = node.content {
3259                for (i, child) in content.iter().enumerate() {
3260                    // Separate consecutive paragraph siblings with a blank
3261                    // blockquote-prefixed line so they re-parse as distinct
3262                    // paragraphs rather than being merged into one (issue #531).
3263                    if i > 0
3264                        && child.node_type == "paragraph"
3265                        && content[i - 1].node_type == "paragraph"
3266                    {
3267                        output.push_str(">\n");
3268                    }
3269                    let mut inner = String::new();
3270                    render_block_node(child, &mut inner, opts);
3271                    for line in inner.lines() {
3272                        output.push_str("> ");
3273                        output.push_str(line);
3274                        output.push('\n');
3275                    }
3276                }
3277            }
3278        }
3279        "bulletList" => {
3280            if let Some(ref items) = node.content {
3281                for item in items {
3282                    output.push_str("- ");
3283                    let content_start = output.len();
3284                    render_list_item_content(item, output, opts);
3285                    // If the rendered content begins with a sequence the
3286                    // bullet-list parser would interpret as a task checkbox
3287                    // marker, escape the leading `[` so the round-trip
3288                    // preserves this as a `bulletList` rather than promoting
3289                    // it to a `taskList` (issue #548).
3290                    if starts_with_task_marker(&output[content_start..]) {
3291                        output.insert(content_start, '\\');
3292                    }
3293                }
3294            }
3295        }
3296        "orderedList" => {
3297            let start = node
3298                .attrs
3299                .as_ref()
3300                .and_then(|a| a.get("order"))
3301                .and_then(serde_json::Value::as_u64)
3302                .unwrap_or(1);
3303            if let Some(ref items) = node.content {
3304                for (i, item) in items.iter().enumerate() {
3305                    let num = start + i as u64;
3306                    output.push_str(&format!("{num}. "));
3307                    render_list_item_content(item, output, opts);
3308                }
3309            }
3310        }
3311        "taskList" => {
3312            if let Some(ref items) = node.content {
3313                for item in items {
3314                    if item.node_type == "taskList" {
3315                        // A nested taskList is a sibling child of the outer
3316                        // taskList — render it indented so it round-trips back
3317                        // as a taskList, not a taskItem (issue #506).
3318                        let mut nested = String::new();
3319                        render_block_node(item, &mut nested, opts);
3320                        for line in nested.lines() {
3321                            output.push_str("  ");
3322                            output.push_str(line);
3323                            output.push('\n');
3324                        }
3325                    } else {
3326                        let state = item
3327                            .attrs
3328                            .as_ref()
3329                            .and_then(|a| a.get("state"))
3330                            .and_then(serde_json::Value::as_str)
3331                            .unwrap_or("TODO");
3332                        if state == "DONE" {
3333                            output.push_str("- [x] ");
3334                        } else {
3335                            output.push_str("- [ ] ");
3336                        }
3337                        render_list_item_content(item, output, opts);
3338                    }
3339                }
3340            }
3341        }
3342        "rule" => {
3343            output.push_str("---\n");
3344        }
3345        "table" => {
3346            render_table(node, output, opts);
3347        }
3348        "mediaSingle" => {
3349            if let Some(ref content) = node.content {
3350                for child in content {
3351                    if child.node_type == "media" {
3352                        render_media(child, node.attrs.as_ref(), output, opts);
3353                    }
3354                }
3355                for child in content {
3356                    if child.node_type == "caption" {
3357                        let mut cap_parts = Vec::new();
3358                        if let Some(ref attrs) = child.attrs {
3359                            maybe_push_local_id(attrs, &mut cap_parts, opts);
3360                        }
3361                        if cap_parts.is_empty() {
3362                            output.push_str(":::caption\n");
3363                        } else {
3364                            output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
3365                        }
3366                        if let Some(ref caption_content) = child.content {
3367                            for inline in caption_content {
3368                                render_inline_node(inline, output, opts);
3369                            }
3370                            output.push('\n');
3371                        }
3372                        output.push_str(":::\n");
3373                    }
3374                }
3375            }
3376        }
3377        "blockCard" => {
3378            if let Some(ref attrs) = node.attrs {
3379                let url = attrs
3380                    .get("url")
3381                    .and_then(serde_json::Value::as_str)
3382                    .unwrap_or("");
3383                let mut attr_parts = Vec::new();
3384                if url_safe_in_bracket_content(url) {
3385                    output.push_str(&format!("::card[{url}]"));
3386                } else {
3387                    // URL would break `::card[URL]` parsing; use quoted attr form.
3388                    output.push_str("::card[]");
3389                    let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
3390                    attr_parts.push(format!("url=\"{escaped}\""));
3391                }
3392                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3393                    attr_parts.push(format!("layout={layout}"));
3394                }
3395                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
3396                    attr_parts.push(format!("width={width}"));
3397                }
3398                if !attr_parts.is_empty() {
3399                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3400                }
3401                output.push('\n');
3402            }
3403        }
3404        "embedCard" => {
3405            if let Some(ref attrs) = node.attrs {
3406                let url = attrs
3407                    .get("url")
3408                    .and_then(serde_json::Value::as_str)
3409                    .unwrap_or("");
3410                output.push_str(&format!("::embed[{url}]"));
3411                let mut attr_parts = Vec::new();
3412                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3413                    attr_parts.push(format!("layout={layout}"));
3414                }
3415                if let Some(h) = attrs
3416                    .get("originalHeight")
3417                    .and_then(serde_json::Value::as_f64)
3418                {
3419                    attr_parts.push(format!("originalHeight={}", fmt_f64_attr(h)));
3420                }
3421                if let Some(w) = attrs.get("width").and_then(serde_json::Value::as_f64) {
3422                    attr_parts.push(format!("width={}", fmt_f64_attr(w)));
3423                }
3424                if !attr_parts.is_empty() {
3425                    output.push_str(&format!("{{{}}}", attr_parts.join(" ")));
3426                }
3427                output.push('\n');
3428            }
3429        }
3430        "extension" => {
3431            if let Some(ref attrs) = node.attrs {
3432                let ext_type = attrs
3433                    .get("extensionType")
3434                    .and_then(serde_json::Value::as_str)
3435                    .unwrap_or("");
3436                let ext_key = attrs
3437                    .get("extensionKey")
3438                    .and_then(serde_json::Value::as_str)
3439                    .unwrap_or("");
3440                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3441                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3442                    attr_parts.push(format!("layout={layout}"));
3443                }
3444                if let Some(params) = attrs.get("parameters") {
3445                    if let Ok(json_str) = serde_json::to_string(params) {
3446                        attr_parts.push(format!("params='{json_str}'"));
3447                    }
3448                }
3449                maybe_push_local_id(attrs, &mut attr_parts, opts);
3450                output.push_str(&format!("::extension{{{}}}\n", attr_parts.join(" ")));
3451            }
3452        }
3453        "panel" => {
3454            let panel_type = node
3455                .attrs
3456                .as_ref()
3457                .and_then(|a| a.get("panelType"))
3458                .and_then(serde_json::Value::as_str)
3459                .unwrap_or("info");
3460            let mut attr_parts = vec![format!("type={panel_type}")];
3461            if let Some(ref attrs) = node.attrs {
3462                if let Some(icon) = attrs.get("panelIcon").and_then(serde_json::Value::as_str) {
3463                    attr_parts.push(format!("icon=\"{icon}\""));
3464                }
3465                if let Some(color) = attrs.get("panelColor").and_then(serde_json::Value::as_str) {
3466                    attr_parts.push(format!("color=\"{color}\""));
3467                }
3468            }
3469            output.push_str(&format!(":::panel{{{}}}\n", attr_parts.join(" ")));
3470            if let Some(ref content) = node.content {
3471                render_block_children(content, output, opts);
3472            }
3473            output.push_str(":::\n");
3474        }
3475        "expand" | "nestedExpand" => {
3476            let directive_name = if node.node_type == "nestedExpand" {
3477                "nested-expand"
3478            } else {
3479                "expand"
3480            };
3481            let mut attr_parts = Vec::new();
3482            if let Some(t) = node
3483                .attrs
3484                .as_ref()
3485                .and_then(|a| a.get("title"))
3486                .and_then(serde_json::Value::as_str)
3487            {
3488                attr_parts.push(format!("title=\"{t}\""));
3489            }
3490            // Check top-level localId first, then fall back to attrs.localId
3491            if let Some(ref lid) = node.local_id {
3492                if !opts.strip_local_ids && lid != "00000000-0000-0000-0000-000000000000" {
3493                    attr_parts.push(format!("localId={lid}"));
3494                }
3495            } else if let Some(ref attrs) = node.attrs {
3496                maybe_push_local_id(attrs, &mut attr_parts, opts);
3497            }
3498            // Emit top-level parameters as params='...'
3499            if let Some(ref params) = node.parameters {
3500                if let Ok(json_str) = serde_json::to_string(params) {
3501                    attr_parts.push(format!("params='{json_str}'"));
3502                }
3503            }
3504            if attr_parts.is_empty() {
3505                output.push_str(&format!(":::{directive_name}\n"));
3506            } else {
3507                output.push_str(&format!(
3508                    ":::{directive_name}{{{}}}\n",
3509                    attr_parts.join(" ")
3510                ));
3511            }
3512            if let Some(ref content) = node.content {
3513                render_block_children(content, output, opts);
3514            }
3515            output.push_str(":::\n");
3516        }
3517        "layoutSection" => {
3518            output.push_str("::::layout\n");
3519            if let Some(ref content) = node.content {
3520                for child in content {
3521                    if child.node_type == "layoutColumn" {
3522                        let width_str = child
3523                            .attrs
3524                            .as_ref()
3525                            .and_then(|a| a.get("width"))
3526                            .and_then(fmt_numeric_attr)
3527                            .unwrap_or_else(|| "50".to_string());
3528                        let mut parts = vec![format!("width={width_str}")];
3529                        if let Some(ref attrs) = child.attrs {
3530                            maybe_push_local_id(attrs, &mut parts, opts);
3531                        }
3532                        output.push_str(&format!(":::column{{{}}}\n", parts.join(" ")));
3533                        if let Some(ref col_content) = child.content {
3534                            render_block_children(col_content, output, opts);
3535                        }
3536                        output.push_str(":::\n");
3537                    }
3538                }
3539            }
3540            output.push_str("::::\n");
3541        }
3542        "decisionList" => {
3543            output.push_str(":::decisions\n");
3544            if let Some(ref content) = node.content {
3545                for item in content {
3546                    output.push_str("- <> ");
3547                    render_list_item_content(item, output, opts);
3548                }
3549            }
3550            output.push_str(":::\n");
3551        }
3552        "bodiedExtension" => {
3553            if let Some(ref attrs) = node.attrs {
3554                let ext_type = attrs
3555                    .get("extensionType")
3556                    .and_then(serde_json::Value::as_str)
3557                    .unwrap_or("");
3558                let ext_key = attrs
3559                    .get("extensionKey")
3560                    .and_then(serde_json::Value::as_str)
3561                    .unwrap_or("");
3562                let mut attr_parts = vec![format!("type={ext_type}"), format!("key={ext_key}")];
3563                if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
3564                    attr_parts.push(format!("layout={layout}"));
3565                }
3566                if let Some(params) = attrs.get("parameters") {
3567                    if let Ok(json_str) = serde_json::to_string(params) {
3568                        attr_parts.push(format!("params='{json_str}'"));
3569                    }
3570                }
3571                maybe_push_local_id(attrs, &mut attr_parts, opts);
3572                output.push_str(&format!(":::extension{{{}}}\n", attr_parts.join(" ")));
3573                if let Some(ref content) = node.content {
3574                    render_block_children(content, output, opts);
3575                }
3576                output.push_str(":::\n");
3577            }
3578        }
3579        _ => {
3580            // Preserve unsupported nodes as JSON in adf-unsupported code blocks
3581            if let Ok(json) = serde_json::to_string_pretty(node) {
3582                output.push_str("```adf-unsupported\n");
3583                output.push_str(&json);
3584                output.push_str("\n```\n");
3585            }
3586        }
3587    }
3588
3589    // Emit block-level attribute marks (align, indent, breakout) and localId
3590    let mut parts = Vec::new();
3591    if let Some(ref marks) = node.marks {
3592        for mark in marks {
3593            match mark.mark_type.as_str() {
3594                "alignment" => {
3595                    if let Some(align) = mark
3596                        .attrs
3597                        .as_ref()
3598                        .and_then(|a| a.get("align"))
3599                        .and_then(serde_json::Value::as_str)
3600                    {
3601                        parts.push(format!("align={align}"));
3602                    }
3603                }
3604                "indentation" => {
3605                    if let Some(level) = mark
3606                        .attrs
3607                        .as_ref()
3608                        .and_then(|a| a.get("level"))
3609                        .and_then(serde_json::Value::as_u64)
3610                    {
3611                        parts.push(format!("indent={level}"));
3612                    }
3613                }
3614                "breakout" => {
3615                    if let Some(mode) = mark
3616                        .attrs
3617                        .as_ref()
3618                        .and_then(|a| a.get("mode"))
3619                        .and_then(serde_json::Value::as_str)
3620                    {
3621                        parts.push(format!("breakout={mode}"));
3622                    }
3623                    if let Some(width) = mark
3624                        .attrs
3625                        .as_ref()
3626                        .and_then(|a| a.get("width"))
3627                        .and_then(serde_json::Value::as_u64)
3628                    {
3629                        parts.push(format!("breakoutWidth={width}"));
3630                    }
3631                }
3632                _ => {}
3633            }
3634        }
3635    }
3636    // Skip localId for node types that already include it in their directive attrs.
3637    // For paragraphs, localId is included in the ::paragraph directive when the
3638    // paragraph uses directive form (empty or whitespace-only content).
3639    let para_used_directive = node.node_type == "paragraph" && {
3640        let is_empty = node.content.as_ref().map_or(true, Vec::is_empty);
3641        if is_empty {
3642            true
3643        } else {
3644            let mut buf = String::new();
3645            render_inline_content(node, &mut buf, opts);
3646            buf.trim().is_empty() && !buf.is_empty()
3647        }
3648    };
3649    if !matches!(node.node_type.as_str(), "expand" | "nestedExpand") && !para_used_directive {
3650        if let Some(ref attrs) = node.attrs {
3651            maybe_push_local_id(attrs, &mut parts, opts);
3652        }
3653    }
3654    // orderedList with explicit `attrs.order=1` needs a trailing `{order=1}`
3655    // signal so the round-trip can distinguish explicit default from omitted
3656    // attrs (issue #547). Values other than 1 are already encoded by the
3657    // list marker, so no signal is needed.
3658    if node.node_type == "orderedList" {
3659        if let Some(ref attrs) = node.attrs {
3660            if attrs.get("order").and_then(serde_json::Value::as_u64) == Some(1) {
3661                parts.push("order=1".to_string());
3662            }
3663        }
3664    }
3665    if !parts.is_empty() {
3666        output.push_str(&format!("{{{}}}\n", parts.join(" ")));
3667    }
3668}
3669
3670/// Renders the content of a list item (unwraps the paragraph layer).
3671/// Nested block children (e.g. sub-lists) are indented with two spaces.
3672///
3673/// Some ADF producers (e.g. Confluence) emit `taskItem` content without a
3674/// paragraph wrapper — the inline nodes sit directly inside the item.  We
3675/// detect this by checking whether the first child is an inline node type
3676/// and, if so, render *all* leading inline children on the first line.
3677fn render_list_item_content(item: &AdfNode, output: &mut String, opts: &RenderOptions) {
3678    let Some(ref content) = item.content else {
3679        // Still emit localId and newline for items with no content (e.g. empty taskItem).
3680        let bare = AdfNode::text("");
3681        emit_list_item_local_ids(item, &bare, output, opts);
3682        output.push('\n');
3683        return;
3684    };
3685    if content.is_empty() {
3686        let bare = AdfNode::text("");
3687        emit_list_item_local_ids(item, &bare, output, opts);
3688        output.push('\n');
3689        return;
3690    }
3691    let first = &content[0];
3692    let rest_start;
3693    if first.node_type == "paragraph" {
3694        let mut buf = String::new();
3695        render_inline_content(first, &mut buf, opts);
3696        // A trailing hardBreak produces a trailing `\\\n` in the buffer.
3697        // Strip the final newline so it doesn't create a blank line after
3698        // the list item marker, which would split the list on re-parse
3699        // (issue #472).  The `\` is kept so round-trip preserves the
3700        // hardBreak, and `output.push('\n')` below supplies the terminator.
3701        let buf = buf.trim_end_matches('\n');
3702        // Indent continuation lines produced by hardBreaks so they stay
3703        // within the list item when re-parsed (issue #402).
3704        let mut is_first_line = true;
3705        for line in buf.split('\n') {
3706            if is_first_line {
3707                output.push_str(line);
3708                is_first_line = false;
3709            } else {
3710                output.push('\n');
3711                if !line.is_empty() {
3712                    output.push_str("  ");
3713                }
3714                output.push_str(line);
3715            }
3716        }
3717        // Emit paragraph + listItem localIds as trailing inline attrs on the first line
3718        emit_list_item_local_ids(item, first, output, opts);
3719        output.push('\n');
3720        rest_start = 1;
3721    } else if is_inline_node_type(&first.node_type) {
3722        // Inline nodes without a paragraph wrapper — render them directly.
3723        rest_start = content
3724            .iter()
3725            .position(|c| !is_inline_node_type(&c.node_type))
3726            .unwrap_or(content.len());
3727        let mut buf = String::new();
3728        for child in &content[..rest_start] {
3729            render_inline_node(child, &mut buf, opts);
3730        }
3731        // Indent continuation lines produced by hardBreaks so they stay
3732        // within the list item when re-parsed (issue #521).
3733        let buf = buf.trim_end_matches('\n');
3734        let mut is_first_line = true;
3735        for line in buf.split('\n') {
3736            if is_first_line {
3737                output.push_str(line);
3738                is_first_line = false;
3739            } else {
3740                output.push('\n');
3741                if !line.is_empty() {
3742                    output.push_str("  ");
3743                }
3744                output.push_str(line);
3745            }
3746        }
3747        // No paragraph wrapper — pass a bare node so paraLocalId is omitted.
3748        let bare = AdfNode::text("");
3749        emit_list_item_local_ids(item, &bare, output, opts);
3750        output.push('\n');
3751        // Any remaining children are block nodes — fall through to the
3752        // indented-block loop below.
3753    } else if first.node_type == "taskItem" {
3754        // Malformed ADF: taskItem.content contains nested taskItem nodes
3755        // directly (seen in some Confluence pages).  Render them as an
3756        // indented nested task list to preserve the data without
3757        // corrupting the surrounding structure (issue #489).
3758        let bare = AdfNode::text("");
3759        emit_list_item_local_ids(item, &bare, output, opts);
3760        output.push('\n');
3761        for child in content {
3762            if child.node_type == "taskItem" {
3763                let state = child
3764                    .attrs
3765                    .as_ref()
3766                    .and_then(|a| a.get("state"))
3767                    .and_then(serde_json::Value::as_str)
3768                    .unwrap_or("TODO");
3769                let marker = if state == "DONE" { "- [x] " } else { "- [ ] " };
3770                output.push_str("  ");
3771                output.push_str(marker);
3772                render_list_item_content(child, output, opts);
3773            } else {
3774                let mut nested = String::new();
3775                render_block_node(child, &mut nested, opts);
3776                for line in nested.lines() {
3777                    output.push_str("  ");
3778                    output.push_str(line);
3779                    output.push('\n');
3780                }
3781            }
3782        }
3783        return;
3784    } else {
3785        // Block-level first child (e.g. codeBlock, mediaSingle).
3786        // Render to a buffer so we can:
3787        //  1. Append listItem localId attrs to the first line (issue #525)
3788        //  2. Indent continuation lines so multi-line blocks stay inside
3789        //     the list item (issue #511)
3790        let mut buf = String::new();
3791        render_block_node(first, &mut buf, opts);
3792        let bare = AdfNode::text("");
3793        let mut is_first = true;
3794        for line in buf.lines() {
3795            if is_first {
3796                output.push_str(line);
3797                emit_list_item_local_ids(item, &bare, output, opts);
3798                output.push('\n');
3799                is_first = false;
3800            } else {
3801                output.push_str("  ");
3802                output.push_str(line);
3803                output.push('\n');
3804            }
3805        }
3806        rest_start = 1;
3807    }
3808    let rest = &content[rest_start..];
3809    for (i, child) in rest.iter().enumerate() {
3810        // Separate consecutive paragraph siblings with a blank indented
3811        // line so they re-parse as distinct paragraphs rather than being
3812        // merged into one (issue #522).
3813        if child.node_type == "paragraph" {
3814            let prev_is_para = if i == 0 {
3815                // First rest child — check whether the first-line node
3816                // (rendered above) was a paragraph.
3817                first.node_type == "paragraph"
3818            } else {
3819                rest[i - 1].node_type == "paragraph"
3820            };
3821            if prev_is_para {
3822                output.push_str("  \n");
3823            }
3824        }
3825        let mut nested = String::new();
3826        render_block_node(child, &mut nested, opts);
3827        for line in nested.lines() {
3828            output.push_str("  ");
3829            output.push_str(line);
3830            output.push('\n');
3831        }
3832    }
3833}
3834
3835/// Returns `true` if the given ADF node type is an inline node.
3836fn is_inline_node_type(node_type: &str) -> bool {
3837    matches!(
3838        node_type,
3839        "text"
3840            | "hardBreak"
3841            | "inlineCard"
3842            | "emoji"
3843            | "mention"
3844            | "status"
3845            | "date"
3846            | "placeholder"
3847            | "mediaInline"
3848    )
3849}
3850
3851/// Emits trailing `{localId=... paraLocalId=...}` on a list item line
3852/// for both the listItem and its first (unwrapped) paragraph.
3853fn emit_list_item_local_ids(
3854    item: &AdfNode,
3855    paragraph: &AdfNode,
3856    output: &mut String,
3857    opts: &RenderOptions,
3858) {
3859    if opts.strip_local_ids {
3860        return;
3861    }
3862    let mut parts = Vec::new();
3863    if let Some(ref attrs) = item.attrs {
3864        maybe_push_local_id(attrs, &mut parts, opts);
3865    }
3866    if paragraph.node_type == "paragraph" {
3867        let has_real_id = paragraph
3868            .attrs
3869            .as_ref()
3870            .and_then(|a| a.get("localId"))
3871            .and_then(serde_json::Value::as_str)
3872            .filter(|id| !id.is_empty() && *id != "00000000-0000-0000-0000-000000000000");
3873        if let Some(local_id) = has_real_id {
3874            parts.push(format!("paraLocalId={local_id}"));
3875        } else if item.node_type == "taskItem" {
3876            // taskItem content may or may not have a paragraph wrapper;
3877            // emit a sentinel so the round-trip can distinguish the two
3878            // forms and restore the wrapper (issue #478).
3879            parts.push("paraLocalId=_".to_string());
3880        }
3881    }
3882    if !parts.is_empty() {
3883        output.push_str(&format!(" {{{}}}", parts.join(" ")));
3884    }
3885}
3886
3887/// Renders a table node, choosing between pipe table and directive table form.
3888fn render_table(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
3889    let Some(ref rows) = node.content else {
3890        return;
3891    };
3892
3893    if table_qualifies_for_pipe_syntax(rows) {
3894        render_pipe_table(node, rows, output, opts);
3895    } else {
3896        render_directive_table(node, rows, output, opts);
3897    }
3898}
3899
3900/// Checks whether all cells qualify for GFM pipe table syntax:
3901/// - Every cell has exactly one paragraph child with only inline nodes
3902/// - All `tableHeader` nodes appear exclusively in the first row
3903/// - The first row must contain at least one `tableHeader` (pipe tables
3904///   always treat the first row as headers, so `tableCell`-only first rows
3905///   must use directive form to preserve the cell type)
3906fn table_qualifies_for_pipe_syntax(rows: &[AdfNode]) -> bool {
3907    // Tables with caption nodes must use directive form
3908    if rows.iter().any(|n| n.node_type == "caption") {
3909        return false;
3910    }
3911    let mut first_row_has_header = false;
3912    for (row_idx, row) in rows.iter().enumerate() {
3913        let Some(ref cells) = row.content else {
3914            continue;
3915        };
3916        for cell in cells {
3917            // Header cells outside first row → must use directive form
3918            if row_idx > 0 && cell.node_type == "tableHeader" {
3919                return false;
3920            }
3921            if row_idx == 0 && cell.node_type == "tableHeader" {
3922                first_row_has_header = true;
3923            }
3924            // Check cell has exactly one paragraph with only inline content
3925            let Some(ref content) = cell.content else {
3926                continue;
3927            };
3928            if content.len() != 1 || content[0].node_type != "paragraph" {
3929                return false;
3930            }
3931            // hardBreak inside a cell produces a newline that breaks pipe
3932            // table syntax — fall back to directive form
3933            if cell_contains_hard_break(&content[0]) {
3934                return false;
3935            }
3936            // Cell-level marks (e.g., border) cannot be represented in pipe
3937            // form — fall back to directive form
3938            if cell.marks.as_ref().is_some_and(|m| !m.is_empty()) {
3939                return false;
3940            }
3941            // Paragraph-level localId would be lost in pipe form (the paragraph
3942            // is unwrapped into the cell text) — fall back to directive form
3943            if content[0]
3944                .attrs
3945                .as_ref()
3946                .and_then(|a| a.get("localId"))
3947                .is_some()
3948            {
3949                return false;
3950            }
3951        }
3952    }
3953    // First row must have at least one tableHeader for pipe syntax;
3954    // otherwise the round-trip would convert tableCell → tableHeader.
3955    first_row_has_header
3956}
3957
3958/// Returns true if a paragraph node contains any `hardBreak` inline nodes.
3959fn cell_contains_hard_break(paragraph: &AdfNode) -> bool {
3960    paragraph
3961        .content
3962        .as_ref()
3963        .is_some_and(|nodes| nodes.iter().any(|n| n.node_type == "hardBreak"))
3964}
3965
3966/// Renders a table as GFM pipe syntax.
3967fn render_pipe_table(node: &AdfNode, rows: &[AdfNode], output: &mut String, opts: &RenderOptions) {
3968    for (row_idx, row) in rows.iter().enumerate() {
3969        let Some(ref cells) = row.content else {
3970            continue;
3971        };
3972
3973        output.push('|');
3974        for cell in cells {
3975            output.push(' ');
3976            let mut cell_buf = String::new();
3977            render_cell_attrs_prefix(cell, &mut cell_buf);
3978            render_inline_content_from_first_paragraph(cell, &mut cell_buf, opts);
3979            output.push_str(&escape_pipes_in_cell(&cell_buf));
3980            output.push_str(" |");
3981        }
3982        output.push('\n');
3983
3984        // Add separator after header row
3985        if row_idx == 0 {
3986            output.push('|');
3987            for cell in cells {
3988                let align = get_cell_paragraph_alignment(cell);
3989                match align {
3990                    Some("center") => output.push_str(" :---: |"),
3991                    Some("end") => output.push_str(" ---: |"),
3992                    _ => output.push_str(" --- |"),
3993                }
3994            }
3995            output.push('\n');
3996        }
3997    }
3998
3999    // Emit table-level attrs if present
4000    render_table_level_attrs(node, output, opts);
4001}
4002
4003/// Renders a table as `::::table` directive syntax (block-content cells).
4004fn render_directive_table(
4005    node: &AdfNode,
4006    rows: &[AdfNode],
4007    output: &mut String,
4008    opts: &RenderOptions,
4009) {
4010    // Opening fence with attrs
4011    let mut attr_parts = Vec::new();
4012    if let Some(ref attrs) = node.attrs {
4013        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4014            attr_parts.push(format!("layout={layout}"));
4015        }
4016        if let Some(numbered) = attrs
4017            .get("isNumberColumnEnabled")
4018            .and_then(serde_json::Value::as_bool)
4019        {
4020            if numbered {
4021                attr_parts.push("numbered".to_string());
4022            } else {
4023                attr_parts.push("numbered=false".to_string());
4024            }
4025        }
4026        if let Some(tw) = attrs.get("width").and_then(serde_json::Value::as_f64) {
4027            let tw_str = if tw.fract() == 0.0 {
4028                (tw as u64).to_string()
4029            } else {
4030                tw.to_string()
4031            };
4032            attr_parts.push(format!("width={tw_str}"));
4033        }
4034        maybe_push_local_id(attrs, &mut attr_parts, opts);
4035    }
4036    if attr_parts.is_empty() {
4037        output.push_str("::::table\n");
4038    } else {
4039        output.push_str(&format!("::::table{{{}}}\n", attr_parts.join(" ")));
4040    }
4041
4042    for row in rows {
4043        if row.node_type == "caption" {
4044            let mut cap_parts = Vec::new();
4045            if let Some(ref attrs) = row.attrs {
4046                maybe_push_local_id(attrs, &mut cap_parts, opts);
4047            }
4048            if cap_parts.is_empty() {
4049                output.push_str(":::caption\n");
4050            } else {
4051                output.push_str(&format!(":::caption{{{}}}\n", cap_parts.join(" ")));
4052            }
4053            if let Some(ref content) = row.content {
4054                for child in content {
4055                    render_inline_node(child, output, opts);
4056                }
4057                output.push('\n');
4058            }
4059            output.push_str(":::\n");
4060            continue;
4061        }
4062        let Some(ref cells) = row.content else {
4063            continue;
4064        };
4065        // Emit :::tr with optional localId
4066        let mut tr_attrs = Vec::new();
4067        if let Some(ref attrs) = row.attrs {
4068            maybe_push_local_id(attrs, &mut tr_attrs, opts);
4069        }
4070        if tr_attrs.is_empty() {
4071            output.push_str(":::tr\n");
4072        } else {
4073            output.push_str(&format!(":::tr{{{}}}\n", tr_attrs.join(" ")));
4074        }
4075        for cell in cells {
4076            let directive_name = if cell.node_type == "tableHeader" {
4077                "th"
4078            } else {
4079                "td"
4080            };
4081            let mut cell_attr_str = build_cell_attrs_string(cell);
4082            // Append localId to cell attrs if present
4083            if let Some(ref attrs) = cell.attrs {
4084                let mut lid_parts = Vec::new();
4085                maybe_push_local_id(attrs, &mut lid_parts, opts);
4086                if !lid_parts.is_empty() {
4087                    if !cell_attr_str.is_empty() {
4088                        cell_attr_str.push(' ');
4089                    }
4090                    cell_attr_str.push_str(&lid_parts.join(" "));
4091                }
4092            }
4093            // Append border mark attrs if present
4094            if let Some(ref marks) = cell.marks {
4095                for mark in marks {
4096                    if mark.mark_type == "border" {
4097                        if let Some(ref attrs) = mark.attrs {
4098                            if let Some(color) =
4099                                attrs.get("color").and_then(serde_json::Value::as_str)
4100                            {
4101                                if !cell_attr_str.is_empty() {
4102                                    cell_attr_str.push(' ');
4103                                }
4104                                cell_attr_str.push_str(&format!("border-color={color}"));
4105                            }
4106                            if let Some(size) =
4107                                attrs.get("size").and_then(serde_json::Value::as_u64)
4108                            {
4109                                if !cell_attr_str.is_empty() {
4110                                    cell_attr_str.push(' ');
4111                                }
4112                                cell_attr_str.push_str(&format!("border-size={size}"));
4113                            }
4114                        }
4115                    }
4116                }
4117            }
4118            let has_marks = cell.marks.as_ref().is_some_and(|m| !m.is_empty());
4119            if cell_attr_str.is_empty() && cell.attrs.is_none() && !has_marks {
4120                output.push_str(&format!(":::{directive_name}\n"));
4121            } else {
4122                output.push_str(&format!(":::{directive_name}{{{cell_attr_str}}}\n"));
4123            }
4124            if let Some(ref content) = cell.content {
4125                render_block_children(content, output, opts);
4126            }
4127            output.push_str(":::\n");
4128        }
4129        output.push_str(":::\n");
4130    }
4131
4132    output.push_str("::::\n");
4133}
4134
4135/// Returns `true` when an attribute value must be quoted to survive round-trip
4136/// through the `{key=value}` attribute parser (which stops unquoted values at
4137/// whitespace or `}`).
4138fn needs_attr_quoting(value: &str) -> bool {
4139    value.contains(|c: char| c.is_whitespace() || c == '}' || c == '(' || c == ')' || c == ',')
4140}
4141
4142/// Builds a JFM attribute string from ADF cell attributes.
4143fn build_cell_attrs_string(cell: &AdfNode) -> String {
4144    let Some(ref attrs) = cell.attrs else {
4145        return String::new();
4146    };
4147    let mut parts = Vec::new();
4148    if let Some(colspan) = attrs.get("colspan").and_then(serde_json::Value::as_u64) {
4149        parts.push(format!("colspan={colspan}"));
4150    }
4151    if let Some(rowspan) = attrs.get("rowspan").and_then(serde_json::Value::as_u64) {
4152        parts.push(format!("rowspan={rowspan}"));
4153    }
4154    if let Some(bg) = attrs.get("background").and_then(serde_json::Value::as_str) {
4155        if needs_attr_quoting(bg) {
4156            let escaped = bg.replace('\\', "\\\\").replace('"', "\\\"");
4157            parts.push(format!("bg=\"{escaped}\""));
4158        } else {
4159            parts.push(format!("bg={bg}"));
4160        }
4161    }
4162    if let Some(colwidth) = attrs.get("colwidth").and_then(serde_json::Value::as_array) {
4163        let widths: Vec<String> = colwidth
4164            .iter()
4165            .filter_map(|v| {
4166                // Preserve the original number type: integers stay as integers,
4167                // floats stay as floats (e.g. Confluence's 254.0 representation).
4168                if let Some(n) = v.as_u64() {
4169                    Some(n.to_string())
4170                } else if let Some(n) = v.as_f64() {
4171                    if n.fract() == 0.0 {
4172                        format!("{n:.1}")
4173                    } else {
4174                        n.to_string()
4175                    }
4176                    .into()
4177                } else {
4178                    None
4179                }
4180            })
4181            .collect();
4182        if !widths.is_empty() {
4183            parts.push(format!("colwidth={}", widths.join(",")));
4184        }
4185    }
4186    parts.join(" ")
4187}
4188
4189/// Renders `{attrs}` prefix for a pipe table cell (background, colspan, etc.).
4190fn render_cell_attrs_prefix(cell: &AdfNode, output: &mut String) {
4191    let Some(ref _attrs) = cell.attrs else {
4192        return;
4193    };
4194    let attr_str = build_cell_attrs_string(cell);
4195    if attr_str.is_empty() {
4196        output.push_str("{} ");
4197    } else {
4198        output.push_str(&format!("{{{attr_str}}} "));
4199    }
4200}
4201
4202/// Gets the alignment mark value from the paragraph inside a table cell.
4203fn get_cell_paragraph_alignment(cell: &AdfNode) -> Option<&str> {
4204    let content = cell.content.as_ref()?;
4205    let para = content.first()?;
4206    let marks = para.marks.as_ref()?;
4207    marks.iter().find_map(|m| {
4208        if m.mark_type == "alignment" {
4209            m.attrs
4210                .as_ref()
4211                .and_then(|a| a.get("align"))
4212                .and_then(serde_json::Value::as_str)
4213        } else {
4214            None
4215        }
4216    })
4217}
4218
4219/// Emits table-level attributes if present.
4220fn render_table_level_attrs(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4221    if let Some(ref attrs) = node.attrs {
4222        let mut parts = Vec::new();
4223        if let Some(layout) = attrs.get("layout").and_then(serde_json::Value::as_str) {
4224            parts.push(format!("layout={layout}"));
4225        }
4226        if let Some(numbered) = attrs
4227            .get("isNumberColumnEnabled")
4228            .and_then(serde_json::Value::as_bool)
4229        {
4230            if numbered {
4231                parts.push("numbered".to_string());
4232            } else {
4233                parts.push("numbered=false".to_string());
4234            }
4235        }
4236        if let Some(tw_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4237            parts.push(format!("width={tw_str}"));
4238        }
4239        maybe_push_local_id(attrs, &mut parts, opts);
4240        if !parts.is_empty() {
4241            output.push_str(&format!("{{{}}}\n", parts.join(" ")));
4242        }
4243    }
4244}
4245
4246/// Renders inline content from the first paragraph child of a table cell.
4247fn render_inline_content_from_first_paragraph(
4248    cell: &AdfNode,
4249    output: &mut String,
4250    opts: &RenderOptions,
4251) {
4252    if let Some(ref content) = cell.content {
4253        if let Some(first) = content.first() {
4254            if first.node_type == "paragraph" {
4255                render_inline_content(first, output, opts);
4256            }
4257        }
4258    }
4259}
4260
4261/// Appends border mark attributes (border-color, border-size) to a parts vec.
4262fn push_border_mark_attrs(marks: &Option<Vec<AdfMark>>, parts: &mut Vec<String>) {
4263    if let Some(ref marks) = marks {
4264        for mark in marks {
4265            if mark.mark_type == "border" {
4266                if let Some(ref attrs) = mark.attrs {
4267                    if let Some(color) = attrs.get("color").and_then(serde_json::Value::as_str) {
4268                        parts.push(format!("border-color={color}"));
4269                    }
4270                    if let Some(size) = attrs.get("size").and_then(serde_json::Value::as_u64) {
4271                        parts.push(format!("border-size={size}"));
4272                    }
4273                }
4274            }
4275        }
4276    }
4277}
4278
4279/// Renders a media node as a markdown image, with optional parent (mediaSingle) attrs.
4280fn render_media(
4281    node: &AdfNode,
4282    parent_attrs: Option<&serde_json::Value>,
4283    output: &mut String,
4284    opts: &RenderOptions,
4285) {
4286    if let Some(ref attrs) = node.attrs {
4287        let media_type = attrs
4288            .get("type")
4289            .and_then(serde_json::Value::as_str)
4290            .unwrap_or("external");
4291        let alt = attrs
4292            .get("alt")
4293            .and_then(serde_json::Value::as_str)
4294            .unwrap_or("");
4295
4296        if media_type == "file" {
4297            // Confluence file attachment — encode metadata in {attrs} block so it survives round-trip
4298            output.push_str(&format!("![{alt}]()"));
4299            let mut parts = vec!["type=file".to_string()];
4300            if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4301                parts.push(format_kv("id", id));
4302            }
4303            if let Some(collection) = attrs.get("collection").and_then(serde_json::Value::as_str) {
4304                parts.push(format_kv("collection", collection));
4305            }
4306            if let Some(occurrence_key) = attrs
4307                .get("occurrenceKey")
4308                .and_then(serde_json::Value::as_str)
4309            {
4310                parts.push(format_kv("occurrenceKey", occurrence_key));
4311            }
4312            if let Some(height_str) = attrs.get("height").and_then(fmt_numeric_attr) {
4313                parts.push(format!("height={height_str}"));
4314            }
4315            if let Some(width_str) = attrs.get("width").and_then(fmt_numeric_attr) {
4316                parts.push(format!("width={width_str}"));
4317            }
4318            maybe_push_local_id(attrs, &mut parts, opts);
4319            // Encode mediaSingle layout/width/widthType if non-default
4320            if let Some(p_attrs) = parent_attrs {
4321                if let Some(layout) = p_attrs.get("layout").and_then(serde_json::Value::as_str) {
4322                    if layout != "center" {
4323                        parts.push(format!("layout={layout}"));
4324                    }
4325                }
4326                if let Some(ms_width_str) = p_attrs.get("width").and_then(fmt_numeric_attr) {
4327                    parts.push(format!("mediaWidth={ms_width_str}"));
4328                }
4329                if let Some(wt) = p_attrs.get("widthType").and_then(serde_json::Value::as_str) {
4330                    parts.push(format!("widthType={wt}"));
4331                }
4332                if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4333                    parts.push(format!("mode={mode}"));
4334                }
4335            }
4336            push_border_mark_attrs(&node.marks, &mut parts);
4337            output.push_str(&format!("{{{}}}", parts.join(" ")));
4338        } else {
4339            // External image
4340            let url = attrs
4341                .get("url")
4342                .and_then(serde_json::Value::as_str)
4343                .unwrap_or("");
4344            output.push_str(&format!("![{alt}]({url})"));
4345
4346            // Emit {layout=... width=... widthType=... mode=... localId=...} if non-default attrs present
4347            {
4348                let mut parts = Vec::new();
4349                if let Some(p_attrs) = parent_attrs {
4350                    let layout = p_attrs.get("layout").and_then(serde_json::Value::as_str);
4351                    let width_str = p_attrs.get("width").and_then(fmt_numeric_attr);
4352                    let width_type = p_attrs.get("widthType").and_then(serde_json::Value::as_str);
4353                    if let Some(l) = layout {
4354                        if l != "center" {
4355                            parts.push(format!("layout={l}"));
4356                        }
4357                    }
4358                    if let Some(w) = width_str {
4359                        parts.push(format!("width={w}"));
4360                    }
4361                    if let Some(wt) = width_type {
4362                        parts.push(format!("widthType={wt}"));
4363                    }
4364                    if let Some(mode) = p_attrs.get("mode").and_then(serde_json::Value::as_str) {
4365                        parts.push(format!("mode={mode}"));
4366                    }
4367                }
4368                maybe_push_local_id(attrs, &mut parts, opts);
4369                push_border_mark_attrs(&node.marks, &mut parts);
4370                if !parts.is_empty() {
4371                    output.push_str(&format!("{{{}}}", parts.join(" ")));
4372                }
4373            }
4374        }
4375
4376        output.push('\n');
4377    }
4378}
4379
4380/// Renders inline content (text nodes with marks) from a block node's children.
4381fn render_inline_content(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4382    if let Some(ref content) = node.content {
4383        for child in content {
4384            render_inline_node(child, output, opts);
4385        }
4386    }
4387}
4388
4389/// Renders a single inline ADF node to markdown.
4390fn render_inline_node(node: &AdfNode, output: &mut String, opts: &RenderOptions) {
4391    match node.node_type.as_str() {
4392        "text" => {
4393            let text = node.text.as_deref().unwrap_or("");
4394            let marks = node.marks.as_deref().unwrap_or(&[]);
4395            let has_code = marks.iter().any(|m| m.mark_type == "code");
4396            // Issue #477: Escape literal backslashes before the newline
4397            // encoding below so they are not consumed as JFM escape
4398            // sequences on round-trip.  Code marks emit content verbatim,
4399            // so backslash escaping is skipped for them.
4400            let owned;
4401            let text = if !has_code {
4402                owned = text.replace('\\', "\\\\");
4403                owned.as_str()
4404            } else {
4405                text
4406            };
4407            // Issue #454: A literal newline inside a text node is escaped
4408            // as the two-character sequence `\n` so it survives round-trip
4409            // as a single text node instead of splitting into paragraphs or
4410            // being converted to hardBreak nodes.
4411            let owned_nl;
4412            let text = if text.contains('\n') {
4413                owned_nl = text.replace('\n', "\\n");
4414                owned_nl.as_str()
4415            } else {
4416                text
4417            };
4418            // Issue #510: Two or more trailing spaces at the end of a text
4419            // node would be misinterpreted as a hardBreak marker on
4420            // round-trip (and collapse the following paragraph).  Escape the
4421            // last space with a backslash so the parser treats it as a
4422            // literal space instead of a line-break signal.
4423            let owned_ts;
4424            let text = if !has_code && text.ends_with("  ") {
4425                let mut s = text.to_string();
4426                // Insert backslash before the final space: "foo  " → "foo \ "
4427                s.insert(s.len() - 1, '\\');
4428                owned_ts = s;
4429                owned_ts.as_str()
4430            } else {
4431                text
4432            };
4433            render_marked_text(text, marks, output);
4434        }
4435        "hardBreak" => {
4436            output.push_str("\\\n");
4437        }
4438        other => {
4439            // Issue #471: Non-text inline nodes (emoji, status, date, mention, etc.)
4440            // may carry annotation marks. Render the node body first, then wrap it
4441            // in bracketed-span syntax if annotation marks are present.
4442            let mut body = String::new();
4443            render_non_text_inline_body(other, node, &mut body, opts);
4444
4445            let annotations: Vec<&AdfMark> = node
4446                .marks
4447                .as_deref()
4448                .unwrap_or(&[])
4449                .iter()
4450                .filter(|m| m.mark_type == "annotation")
4451                .collect();
4452
4453            if annotations.is_empty() {
4454                output.push_str(&body);
4455            } else {
4456                let mut attr_parts = Vec::new();
4457                for ann in &annotations {
4458                    if let Some(ref attrs) = ann.attrs {
4459                        if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4460                            let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4461                            attr_parts.push(format!("annotation-id=\"{escaped}\""));
4462                        }
4463                        if let Some(at) = attrs
4464                            .get("annotationType")
4465                            .and_then(serde_json::Value::as_str)
4466                        {
4467                            attr_parts.push(format!("annotation-type={at}"));
4468                        }
4469                    }
4470                }
4471                output.push('[');
4472                output.push_str(&body);
4473                output.push_str("]{");
4474                output.push_str(&attr_parts.join(" "));
4475                output.push('}');
4476            }
4477        }
4478    }
4479}
4480
4481/// Renders the body of a non-text inline node (without mark wrapping).
4482fn render_non_text_inline_body(
4483    node_type: &str,
4484    node: &AdfNode,
4485    output: &mut String,
4486    opts: &RenderOptions,
4487) {
4488    match node_type {
4489        "inlineCard" => {
4490            if let Some(ref attrs) = node.attrs {
4491                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4492                    let mut attr_parts = Vec::new();
4493                    if url_safe_in_bracket_content(url) {
4494                        output.push_str(":card[");
4495                        output.push_str(url);
4496                        output.push(']');
4497                    } else {
4498                        // URL would break `:card[URL]` parsing (e.g. contains an
4499                        // unbalanced `]` or a newline).  Fall back to quoted
4500                        // attribute form so the URL round-trips losslessly.
4501                        output.push_str(":card[]");
4502                        let escaped = url.replace('\\', "\\\\").replace('"', "\\\"");
4503                        attr_parts.push(format!("url=\"{escaped}\""));
4504                    }
4505                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4506                    if !attr_parts.is_empty() {
4507                        output.push('{');
4508                        output.push_str(&attr_parts.join(" "));
4509                        output.push('}');
4510                    }
4511                }
4512            }
4513        }
4514        "emoji" => {
4515            if let Some(ref attrs) = node.attrs {
4516                if let Some(short_name) = attrs.get("shortName").and_then(serde_json::Value::as_str)
4517                {
4518                    output.push(':');
4519                    let name = short_name.strip_prefix(':').unwrap_or(short_name);
4520                    let name = name.strip_suffix(':').unwrap_or(name);
4521                    output.push_str(name);
4522                    output.push(':');
4523
4524                    let mut parts = Vec::new();
4525                    let escaped_sn = short_name.replace('\\', "\\\\").replace('"', "\\\"");
4526                    parts.push(format!("shortName=\"{escaped_sn}\""));
4527                    if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4528                        let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4529                        parts.push(format!("id=\"{escaped}\""));
4530                    }
4531                    if let Some(text) = attrs.get("text").and_then(serde_json::Value::as_str) {
4532                        let escaped = text.replace('\\', "\\\\").replace('"', "\\\"");
4533                        parts.push(format!("text=\"{escaped}\""));
4534                    }
4535                    maybe_push_local_id(attrs, &mut parts, opts);
4536                    output.push('{');
4537                    output.push_str(&parts.join(" "));
4538                    output.push('}');
4539                }
4540            }
4541        }
4542        "status" => {
4543            if let Some(ref attrs) = node.attrs {
4544                let text = attrs
4545                    .get("text")
4546                    .and_then(serde_json::Value::as_str)
4547                    .unwrap_or("");
4548                let color = attrs
4549                    .get("color")
4550                    .and_then(serde_json::Value::as_str)
4551                    .unwrap_or("neutral");
4552                let mut attr_parts = vec![format!("color={color}")];
4553                if let Some(style) = attrs.get("style").and_then(serde_json::Value::as_str) {
4554                    attr_parts.push(format!("style={style}"));
4555                }
4556                maybe_push_local_id(attrs, &mut attr_parts, opts);
4557                output.push_str(&format!(":status[{text}]{{{}}}", attr_parts.join(" ")));
4558            }
4559        }
4560        "date" => {
4561            if let Some(ref attrs) = node.attrs {
4562                if let Some(timestamp) = attrs.get("timestamp").and_then(serde_json::Value::as_str)
4563                {
4564                    let display = epoch_ms_to_iso_date(timestamp);
4565                    let mut attr_parts = vec![format!("timestamp={timestamp}")];
4566                    maybe_push_local_id(attrs, &mut attr_parts, opts);
4567                    output.push_str(&format!(":date[{display}]{{{}}}", attr_parts.join(" ")));
4568                }
4569            }
4570        }
4571        "mention" => {
4572            if let Some(ref attrs) = node.attrs {
4573                let id = attrs
4574                    .get("id")
4575                    .and_then(serde_json::Value::as_str)
4576                    .unwrap_or("");
4577                let text = attrs
4578                    .get("text")
4579                    .and_then(serde_json::Value::as_str)
4580                    .unwrap_or("");
4581                let mut attr_parts = vec![format!("id={id}")];
4582                if let Some(ut) = attrs.get("userType").and_then(serde_json::Value::as_str) {
4583                    attr_parts.push(format!("userType={ut}"));
4584                }
4585                if let Some(al) = attrs.get("accessLevel").and_then(serde_json::Value::as_str) {
4586                    attr_parts.push(format!("accessLevel={al}"));
4587                }
4588                maybe_push_local_id(attrs, &mut attr_parts, opts);
4589                output.push_str(&format!(":mention[{text}]{{{}}}", attr_parts.join(" ")));
4590            }
4591        }
4592        "placeholder" => {
4593            if let Some(ref attrs) = node.attrs {
4594                let text = attrs
4595                    .get("text")
4596                    .and_then(serde_json::Value::as_str)
4597                    .unwrap_or("");
4598                output.push_str(&format!(":placeholder[{text}]"));
4599            }
4600        }
4601        "inlineExtension" => {
4602            if let Some(ref attrs) = node.attrs {
4603                let ext_type = attrs
4604                    .get("extensionType")
4605                    .and_then(serde_json::Value::as_str)
4606                    .unwrap_or("");
4607                let ext_key = attrs
4608                    .get("extensionKey")
4609                    .and_then(serde_json::Value::as_str)
4610                    .unwrap_or("");
4611                let fallback = node.text.as_deref().unwrap_or("");
4612                output.push_str(&format!(
4613                    ":extension[{fallback}]{{type={ext_type} key={ext_key}}}"
4614                ));
4615            }
4616        }
4617        "mediaInline" => {
4618            if let Some(ref attrs) = node.attrs {
4619                let mut attr_parts = Vec::new();
4620                if let Some(media_type) = attrs.get("type").and_then(serde_json::Value::as_str) {
4621                    attr_parts.push(format_kv("type", media_type));
4622                }
4623                if let Some(id) = attrs.get("id").and_then(serde_json::Value::as_str) {
4624                    attr_parts.push(format_kv("id", id));
4625                }
4626                if let Some(collection) =
4627                    attrs.get("collection").and_then(serde_json::Value::as_str)
4628                {
4629                    attr_parts.push(format_kv("collection", collection));
4630                }
4631                if let Some(url) = attrs.get("url").and_then(serde_json::Value::as_str) {
4632                    attr_parts.push(format_kv("url", url));
4633                }
4634                if let Some(alt) = attrs.get("alt").and_then(serde_json::Value::as_str) {
4635                    attr_parts.push(format_kv("alt", alt));
4636                }
4637                if let Some(width) = attrs.get("width").and_then(serde_json::Value::as_u64) {
4638                    attr_parts.push(format!("width={width}"));
4639                }
4640                if let Some(height) = attrs.get("height").and_then(serde_json::Value::as_u64) {
4641                    attr_parts.push(format!("height={height}"));
4642                }
4643                maybe_push_local_id(attrs, &mut attr_parts, opts);
4644                output.push_str(&format!(":media-inline[]{{{}}}", attr_parts.join(" ")));
4645            }
4646        }
4647        _ => {
4648            // Preserve an unsupported inline node losslessly as an
4649            // `:adf-unsupported[]{json="…"}` directive carrying the full node
4650            // JSON — the inline mirror of the block-level ```adf-unsupported```
4651            // fence (ADR-0029). Read back by `try_dispatch_inline_directive`,
4652            // so a read → edit → write round-trips instead of collapsing to a
4653            // lossy `<!-- unsupported inline -->` comment (issue #1117).
4654            if let Ok(json) = serde_json::to_string(node) {
4655                output.push_str(":adf-unsupported[]{");
4656                output.push_str(&format_kv("json", &json));
4657                output.push('}');
4658            }
4659        }
4660    }
4661}
4662
4663/// Renders text with ADF marks applied as markdown syntax.
4664///
4665/// Mark ordering is preserved by walking the marks array in order and emitting
4666/// one wrapper per mark (outermost first, innermost last).  The resulting
4667/// markdown round-trips back to the original mark sequence because the parser
4668/// reconstructs marks outside-in from the nested delimiter structure.
4669///
4670/// When both `strong` and `em` are present, em is rendered with `_` instead of
4671/// `*` to avoid ambiguity (e.g., `_**text**_` rather than `***text***`).  The
4672/// single exception is `[strong, em]` (exactly those two marks in that order),
4673/// which is rendered as `***text***` to preserve the familiar compact form;
4674/// the parser's triple-delimiter rule round-trips it back to `[strong, em]`.
4675fn render_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4676    if marks.iter().any(|m| m.mark_type == "code") {
4677        render_code_marked_text(text, marks, output);
4678        return;
4679    }
4680
4681    let has_link = marks.iter().any(|m| m.mark_type == "link");
4682    let has_strong = marks.iter().any(|m| m.mark_type == "strong");
4683    let has_em = marks.iter().any(|m| m.mark_type == "em");
4684
4685    // Compact form for the common [strong, em] case: ***text***.  em is
4686    // rendered with `*` here (as part of the `***` triple delimiter), so
4687    // underscores in the content don't need escaping.
4688    if marks.len() == 2 && marks[0].mark_type == "strong" && marks[1].mark_type == "em" {
4689        let escaped = escape_emphasis_markers(text);
4690        let escaped = escape_emoji_shortcodes(&escaped);
4691        let escaped = escape_backticks(&escaped);
4692        let escaped = escape_bare_urls(&escaped);
4693        output.push_str("***");
4694        output.push_str(&escaped);
4695        output.push_str("***");
4696        return;
4697    }
4698
4699    // When both strong and em are present (in any order), em uses `_` instead
4700    // of `*` to avoid the `***` triple-delimiter ambiguity.  Otherwise em uses
4701    // `*`, which sidesteps intraword-underscore pitfalls for plain em text.
4702    let em_delim = if has_strong && has_em { "_" } else { "*" };
4703
4704    // Text must also escape `_` when em renders as `_..._` — otherwise any
4705    // underscore in the content would close the emphasis span early.
4706    let escaped = if em_delim == "_" {
4707        escape_emphasis_markers_with_underscore(text)
4708    } else {
4709        escape_emphasis_markers(text)
4710    };
4711    let escaped = escape_emoji_shortcodes(&escaped);
4712    let escaped = escape_backticks(&escaped);
4713    // Always escape bare URLs so they are not re-parsed as `inlineCard`
4714    // nodes on round-trip.  When the text carries a link mark, also escape
4715    // `[` and `]` so they do not terminate the enclosing `[…]` link syntax
4716    // (issue #493).  Escaping bare URLs inside link text additionally
4717    // prevents `\[`/`\]` escapes from leaking through the URL-as-link-text
4718    // fast path and from corrupting an auto-detected bare URL inside the
4719    // link display text (issue #551).
4720    let escaped = escape_bare_urls(&escaped);
4721    let escaped = if has_link {
4722        escape_link_brackets(&escaped)
4723    } else {
4724        escaped
4725    };
4726
4727    // Collect (open, close) wrappers in mark order, outermost first.  Consecutive
4728    // span-attr or bracketed-span marks that happen to be in the parser's
4729    // canonical order (so the merged wrapper parses back to the same mark
4730    // sequence) are merged into a single wrapper; otherwise each mark gets its
4731    // own nested wrapper so that the mark ordering survives the round-trip.
4732    let mut wrappers: Vec<(String, String)> = Vec::new();
4733    let mut i = 0;
4734    while i < marks.len() {
4735        match marks[i].mark_type.as_str() {
4736            "em" => {
4737                wrappers.push((em_delim.to_string(), em_delim.to_string()));
4738                i += 1;
4739            }
4740            "strong" => {
4741                wrappers.push(("**".to_string(), "**".to_string()));
4742                i += 1;
4743            }
4744            "strike" => {
4745                wrappers.push(("~~".to_string(), "~~".to_string()));
4746                i += 1;
4747            }
4748            "link" => {
4749                let href = link_href(&marks[i]);
4750                wrappers.push(("[".to_string(), format!("]({href})")));
4751                i += 1;
4752            }
4753            "textColor" | "backgroundColor" | "subsup" => {
4754                let start = i;
4755                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4756                    i += 1;
4757                }
4758                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4759            }
4760            "underline" | "annotation" => {
4761                let start = i;
4762                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4763                    i += 1;
4764                }
4765                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4766            }
4767            _ => {
4768                i += 1;
4769            }
4770        }
4771    }
4772
4773    // Apply wrappers from innermost (last) to outermost (first).
4774    let mut result = escaped;
4775    for (open, close) in wrappers.iter().rev() {
4776        result.insert_str(0, open);
4777        result.push_str(close);
4778    }
4779    output.push_str(&result);
4780}
4781
4782/// Renders a text node with a `code` mark.  Code content is emitted verbatim
4783/// inside backticks, optionally wrapped by a link and/or by `:span`/bracketed-
4784/// span carrying span-attr (`textColor`, `backgroundColor`, `subsup`) and
4785/// bracketed-span (`underline`, `annotation`) marks.  No `em`/`strong`/`strike`
4786/// formatting is applied because markdown code spans do not support nested
4787/// emphasis (issue #554: previously textColor/bg/subsup/underline were
4788/// silently dropped when combined with a code mark).
4789fn render_code_marked_text(text: &str, marks: &[AdfMark], output: &mut String) {
4790    let link_mark = marks.iter().find(|m| m.mark_type == "link");
4791
4792    let mut code_str = String::new();
4793    if let Some(link_mark) = link_mark {
4794        let href = link_href(link_mark);
4795        code_str.push('[');
4796        render_inline_code(text, &mut code_str);
4797        code_str.push_str("](");
4798        code_str.push_str(href);
4799        code_str.push(')');
4800    } else {
4801        render_inline_code(text, &mut code_str);
4802    }
4803
4804    // Build wrappers (outermost first) for span-attr and bracketed-span runs,
4805    // walking marks in order so the round-trip preserves mark ordering.
4806    let mut wrappers: Vec<(String, String)> = Vec::new();
4807    let mut i = 0;
4808    while i < marks.len() {
4809        match marks[i].mark_type.as_str() {
4810            "textColor" | "backgroundColor" | "subsup" => {
4811                let start = i;
4812                while i < marks.len() && is_span_attr_mark(&marks[i].mark_type) {
4813                    i += 1;
4814                }
4815                emit_span_attr_wrappers(&marks[start..i], &mut wrappers);
4816            }
4817            "underline" | "annotation" => {
4818                let start = i;
4819                while i < marks.len() && is_bracketed_span_mark(&marks[i].mark_type) {
4820                    i += 1;
4821                }
4822                emit_bracketed_wrappers(&marks[start..i], &mut wrappers);
4823            }
4824            _ => {
4825                i += 1;
4826            }
4827        }
4828    }
4829
4830    // Apply wrappers from innermost (last) to outermost (first).
4831    let mut result = code_str;
4832    for (open, close) in wrappers.iter().rev() {
4833        result.insert_str(0, open);
4834        result.push_str(close);
4835    }
4836    output.push_str(&result);
4837}
4838
4839/// Collects `:span` attribute fragments (color, bg, sub/sup) for a single mark.
4840fn collect_span_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4841    match mark.mark_type.as_str() {
4842        "textColor" => {
4843            if let Some(c) = mark
4844                .attrs
4845                .as_ref()
4846                .and_then(|a| a.get("color"))
4847                .and_then(serde_json::Value::as_str)
4848            {
4849                attrs.push(format!("color={c}"));
4850            }
4851        }
4852        "backgroundColor" => {
4853            if let Some(c) = mark
4854                .attrs
4855                .as_ref()
4856                .and_then(|a| a.get("color"))
4857                .and_then(serde_json::Value::as_str)
4858            {
4859                attrs.push(format!("bg={c}"));
4860            }
4861        }
4862        "subsup" => {
4863            if let Some(kind) = mark
4864                .attrs
4865                .as_ref()
4866                .and_then(|a| a.get("type"))
4867                .and_then(serde_json::Value::as_str)
4868            {
4869                attrs.push(kind.to_string());
4870            }
4871        }
4872        _ => {}
4873    }
4874}
4875
4876/// Collects bracketed-span attribute fragments for an `underline` or `annotation` mark.
4877fn collect_bracketed_attr(mark: &AdfMark, attrs: &mut Vec<String>) {
4878    match mark.mark_type.as_str() {
4879        "underline" => attrs.push("underline".to_string()),
4880        "annotation" => {
4881            if let Some(ref a) = mark.attrs {
4882                if let Some(id) = a.get("id").and_then(serde_json::Value::as_str) {
4883                    let escaped = id.replace('\\', "\\\\").replace('"', "\\\"");
4884                    attrs.push(format!("annotation-id=\"{escaped}\""));
4885                }
4886                if let Some(at) = a.get("annotationType").and_then(serde_json::Value::as_str) {
4887                    attrs.push(format!("annotation-type={at}"));
4888                }
4889            }
4890        }
4891        _ => {}
4892    }
4893}
4894
4895fn is_span_attr_mark(mark_type: &str) -> bool {
4896    matches!(mark_type, "textColor" | "backgroundColor" | "subsup")
4897}
4898
4899fn is_bracketed_span_mark(mark_type: &str) -> bool {
4900    matches!(mark_type, "underline" | "annotation")
4901}
4902
4903/// Canonical ordering for span-attr marks, matching the order in which the
4904/// `:span` directive parser reads attributes (`color`, then `bg`, then
4905/// `sub`/`sup`).
4906fn span_attr_order(mark_type: &str) -> u8 {
4907    match mark_type {
4908        "textColor" => 0,
4909        "backgroundColor" => 1,
4910        "subsup" => 2,
4911        _ => u8::MAX,
4912    }
4913}
4914
4915/// Returns `true` if the run of span-attr marks is in the canonical order the
4916/// `:span` parser would produce.  A canonical run can be merged into one
4917/// `:span[...]{...}` wrapper; a non-canonical run must be split into one
4918/// nested wrapper per mark so the ordering survives the round-trip.
4919fn span_run_is_canonical(run: &[AdfMark]) -> bool {
4920    let mut prev = 0;
4921    for m in run {
4922        let order = span_attr_order(&m.mark_type);
4923        if order == u8::MAX || order < prev {
4924            return false;
4925        }
4926        prev = order;
4927    }
4928    true
4929}
4930
4931/// Returns `true` if the run of `underline`/`annotation` marks is in the
4932/// canonical order the bracketed-span parser produces (`underline` first,
4933/// followed by annotations).  A canonical run can be merged into one
4934/// `[...]{underline annotation-id=...}` wrapper.
4935fn bracketed_run_is_canonical(run: &[AdfMark]) -> bool {
4936    let mut seen_annotation = false;
4937    for m in run {
4938        match m.mark_type.as_str() {
4939            "underline" => {
4940                if seen_annotation {
4941                    return false;
4942                }
4943            }
4944            "annotation" => seen_annotation = true,
4945            _ => return false,
4946        }
4947    }
4948    true
4949}
4950
4951/// Emits one or more `:span[...]{...}` wrappers for a run of span-attr marks.
4952/// Canonical-order runs collapse into a single wrapper; non-canonical runs
4953/// emit one wrapper per mark so the order round-trips.
4954fn emit_span_attr_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4955    if span_run_is_canonical(run) {
4956        let mut attrs = Vec::new();
4957        for m in run {
4958            collect_span_attr(m, &mut attrs);
4959        }
4960        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4961        return;
4962    }
4963    for m in run {
4964        let mut attrs = Vec::new();
4965        collect_span_attr(m, &mut attrs);
4966        wrappers.push((":span[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4967    }
4968}
4969
4970/// Emits one or more `[...]{...}` wrappers for a run of `underline`/`annotation`
4971/// marks.  Canonical-order runs collapse into a single wrapper; non-canonical
4972/// runs emit one wrapper per mark so the order round-trips.
4973fn emit_bracketed_wrappers(run: &[AdfMark], wrappers: &mut Vec<(String, String)>) {
4974    if bracketed_run_is_canonical(run) {
4975        let mut attrs = Vec::new();
4976        for m in run {
4977            collect_bracketed_attr(m, &mut attrs);
4978        }
4979        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4980        return;
4981    }
4982    for m in run {
4983        let mut attrs = Vec::new();
4984        collect_bracketed_attr(m, &mut attrs);
4985        wrappers.push(("[".to_string(), format!("]{{{}}}", attrs.join(" "))));
4986    }
4987}
4988
4989/// Extracts the href from a link mark.
4990fn link_href(mark: &AdfMark) -> &str {
4991    mark.attrs
4992        .as_ref()
4993        .and_then(|a| a.get("href"))
4994        .and_then(serde_json::Value::as_str)
4995        .unwrap_or("")
4996}
4997
4998#[cfg(test)]
4999#[allow(
5000    clippy::unwrap_used,
5001    clippy::expect_used,
5002    clippy::needless_update,
5003    clippy::needless_collect,
5004    duplicate_macro_attributes
5005)]
5006mod tests {
5007    use super::*;
5008
5009    // ── adf_to_plain_text tests ─────────────────────────────────────
5010
5011    #[test]
5012    fn adf_to_plain_text_single_paragraph() {
5013        let doc = markdown_to_adf("Hello world").unwrap();
5014        assert_eq!(adf_to_plain_text(&doc), "Hello world");
5015    }
5016
5017    #[test]
5018    fn adf_to_plain_text_multiple_paragraphs_space_separated() {
5019        let doc = markdown_to_adf("Alpha\n\nBeta").unwrap();
5020        let plain = adf_to_plain_text(&doc);
5021        // Blocks are space-separated so multi-paragraph anchor selections match.
5022        assert!(plain.contains("Alpha"));
5023        assert!(plain.contains("Beta"));
5024        assert_eq!(plain, "Alpha Beta");
5025    }
5026
5027    #[test]
5028    fn adf_to_plain_text_drops_marks_but_keeps_text() {
5029        let doc = markdown_to_adf("Hello **bold** world").unwrap();
5030        assert_eq!(adf_to_plain_text(&doc), "Hello bold world");
5031    }
5032
5033    #[test]
5034    fn adf_to_plain_text_empty_doc() {
5035        let doc = AdfDocument::new();
5036        assert_eq!(adf_to_plain_text(&doc), "");
5037    }
5038
5039    #[test]
5040    fn adf_to_plain_text_leading_empty_block_emits_no_extra_space() {
5041        // An empty paragraph followed by a text-bearing one must not produce
5042        // a leading space — the separator logic skips when `out` is still empty.
5043        let doc = AdfDocument {
5044            version: 1,
5045            doc_type: "doc".to_string(),
5046            content: vec![
5047                AdfNode {
5048                    node_type: "paragraph".to_string(),
5049                    attrs: None,
5050                    content: Some(vec![]),
5051                    text: None,
5052                    marks: None,
5053                    local_id: None,
5054                    parameters: None,
5055                },
5056                AdfNode {
5057                    node_type: "paragraph".to_string(),
5058                    attrs: None,
5059                    content: Some(vec![AdfNode::text("Hello")]),
5060                    text: None,
5061                    marks: None,
5062                    local_id: None,
5063                    parameters: None,
5064                },
5065            ],
5066        };
5067        assert_eq!(adf_to_plain_text(&doc), "Hello");
5068    }
5069
5070    // ── markdown_to_adf tests ───────────────────────────────────────
5071
5072    #[test]
5073    fn paragraph() {
5074        let doc = markdown_to_adf("Hello world").unwrap();
5075        assert_eq!(doc.content.len(), 1);
5076        assert_eq!(doc.content[0].node_type, "paragraph");
5077    }
5078
5079    #[test]
5080    fn heading_levels() {
5081        for level in 1..=6 {
5082            let hashes = "#".repeat(level);
5083            let md = format!("{hashes} Title");
5084            let doc = markdown_to_adf(&md).unwrap();
5085            assert_eq!(doc.content[0].node_type, "heading");
5086            let attrs = doc.content[0].attrs.as_ref().unwrap();
5087            assert_eq!(attrs["level"], level as u64);
5088        }
5089    }
5090
5091    // ── issue #1005: inline `code` is not permitted on headings ─────
5092
5093    #[test]
5094    fn heading_inline_code_mark_stripped() {
5095        // `### `GET /api`` parses an inline-code span; ADF forbids the `code`
5096        // mark on headings, so it is stripped and the text kept as plain.
5097        let doc = markdown_to_adf("### `GET /api`").unwrap();
5098        let heading = &doc.content[0];
5099        assert_eq!(heading.node_type, "heading");
5100        let content = heading.content.as_ref().unwrap();
5101        assert_eq!(content.len(), 1);
5102        assert_eq!(content[0].text.as_deref(), Some("GET /api"));
5103        assert!(
5104            content[0].marks.is_none(),
5105            "expected no marks, got: {:?}",
5106            content[0].marks
5107        );
5108    }
5109
5110    #[test]
5111    fn heading_code_strip_preserves_sibling_strong() {
5112        // `### **`x`**` → text `x` carrying `strong`; only `code` is removed.
5113        let doc = markdown_to_adf("### **`x`**").unwrap();
5114        let content = doc.content[0].content.as_ref().unwrap();
5115        let marks = content[0].marks.as_ref().unwrap();
5116        assert!(marks.iter().any(|m| m.mark_type == "strong"));
5117        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5118    }
5119
5120    #[test]
5121    fn heading_code_strip_preserves_link() {
5122        // `### [`x`](url)` → text `x` carrying `link`; only `code` is removed.
5123        let doc = markdown_to_adf("### [`x`](https://e.com)").unwrap();
5124        let content = doc.content[0].content.as_ref().unwrap();
5125        let marks = content[0].marks.as_ref().unwrap();
5126        assert!(marks.iter().any(|m| m.mark_type == "link"));
5127        assert!(!marks.iter().any(|m| m.mark_type == "code"));
5128    }
5129
5130    #[test]
5131    fn heading_with_code_now_passes_validation() {
5132        // End-to-end guard: the converted document no longer trips the ADF
5133        // mark validator that previously rejected it at write time.
5134        let doc = markdown_to_adf("### `GET /api`").unwrap();
5135        let violations = crate::atlassian::adf_schema::validate_document(&doc);
5136        assert!(violations.is_empty(), "got: {violations:?}");
5137    }
5138
5139    #[test]
5140    fn paragraph_inline_code_mark_preserved() {
5141        // Regression guard: the strip is heading-only — paragraph inline code
5142        // keeps its `code` mark.
5143        let doc = markdown_to_adf("`x`").unwrap();
5144        let content = doc.content[0].content.as_ref().unwrap();
5145        let marks = content[0].marks.as_ref().unwrap();
5146        assert!(marks.iter().any(|m| m.mark_type == "code"));
5147    }
5148
5149    #[test]
5150    fn code_block() {
5151        let md = "```rust\nfn main() {}\n```";
5152        let doc = markdown_to_adf(md).unwrap();
5153        assert_eq!(doc.content[0].node_type, "codeBlock");
5154        let attrs = doc.content[0].attrs.as_ref().unwrap();
5155        assert_eq!(attrs["language"], "rust");
5156    }
5157
5158    #[test]
5159    fn code_block_no_language() {
5160        let md = "```\nsome code\n```";
5161        let doc = markdown_to_adf(md).unwrap();
5162        assert_eq!(doc.content[0].node_type, "codeBlock");
5163        assert!(doc.content[0].attrs.is_none());
5164    }
5165
5166    #[test]
5167    fn code_block_empty_language() {
5168        let md = "```\"\"\nsome code\n```";
5169        let doc = markdown_to_adf(md).unwrap();
5170        assert_eq!(doc.content[0].node_type, "codeBlock");
5171        let attrs = doc.content[0].attrs.as_ref().unwrap();
5172        assert_eq!(attrs["language"], "");
5173    }
5174
5175    #[test]
5176    fn horizontal_rule() {
5177        let doc = markdown_to_adf("---").unwrap();
5178        assert_eq!(doc.content[0].node_type, "rule");
5179    }
5180
5181    #[test]
5182    fn horizontal_rule_stars() {
5183        let doc = markdown_to_adf("***").unwrap();
5184        assert_eq!(doc.content[0].node_type, "rule");
5185    }
5186
5187    #[test]
5188    fn blockquote() {
5189        let md = "> This is a quote\n> Second line";
5190        let doc = markdown_to_adf(md).unwrap();
5191        assert_eq!(doc.content[0].node_type, "blockquote");
5192    }
5193
5194    #[test]
5195    fn bullet_list() {
5196        let md = "- Item 1\n- Item 2\n- Item 3";
5197        let doc = markdown_to_adf(md).unwrap();
5198        assert_eq!(doc.content[0].node_type, "bulletList");
5199        let items = doc.content[0].content.as_ref().unwrap();
5200        assert_eq!(items.len(), 3);
5201    }
5202
5203    #[test]
5204    fn ordered_list() {
5205        let md = "1. First\n2. Second\n3. Third";
5206        let doc = markdown_to_adf(md).unwrap();
5207        assert_eq!(doc.content[0].node_type, "orderedList");
5208        let items = doc.content[0].content.as_ref().unwrap();
5209        assert_eq!(items.len(), 3);
5210    }
5211
5212    #[test]
5213    fn task_list() {
5214        let md = "- [ ] Todo item\n- [x] Done item";
5215        let doc = markdown_to_adf(md).unwrap();
5216        assert_eq!(doc.content[0].node_type, "taskList");
5217        let items = doc.content[0].content.as_ref().unwrap();
5218        assert_eq!(items.len(), 2);
5219        assert_eq!(items[0].node_type, "taskItem");
5220        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5221        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5222    }
5223
5224    #[test]
5225    fn task_list_uppercase_x() {
5226        let md = "- [X] Done item";
5227        let doc = markdown_to_adf(md).unwrap();
5228        assert_eq!(doc.content[0].node_type, "taskList");
5229        let item = &doc.content[0].content.as_ref().unwrap()[0];
5230        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
5231    }
5232
5233    /// Issue #548: an empty task marker (no trailing space) must still be
5234    /// parsed as a `taskList` rather than a `bulletList` with `[ ]` text.
5235    #[test]
5236    fn task_list_empty_todo_no_trailing_space() {
5237        let md = "- [ ]";
5238        let doc = markdown_to_adf(md).unwrap();
5239        assert_eq!(doc.content[0].node_type, "taskList");
5240        let items = doc.content[0].content.as_ref().unwrap();
5241        assert_eq!(items.len(), 1);
5242        assert_eq!(items[0].node_type, "taskItem");
5243        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5244        assert!(items[0].content.is_none());
5245    }
5246
5247    /// Issue #548: likewise for a done checkbox with no body.
5248    #[test]
5249    fn task_list_empty_done_no_trailing_space() {
5250        let md = "- [x]\n- [X]";
5251        let doc = markdown_to_adf(md).unwrap();
5252        assert_eq!(doc.content[0].node_type, "taskList");
5253        let items = doc.content[0].content.as_ref().unwrap();
5254        assert_eq!(items.len(), 2);
5255        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
5256        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5257    }
5258
5259    /// Issue #548: the body of `- [ ] text` must not have a spurious leading
5260    /// space introduced by relaxing the trailing-space requirement.
5261    #[test]
5262    fn task_list_body_has_no_leading_space() {
5263        let md = "- [ ] Buy groceries";
5264        let doc = markdown_to_adf(md).unwrap();
5265        let item = &doc.content[0].content.as_ref().unwrap()[0];
5266        let text = item.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5267        assert_eq!(text, "Buy groceries");
5268    }
5269
5270    /// Issue #548: round-trip from ADF with empty taskItems should preserve
5271    /// the `taskList` structure even if trailing spaces are stripped from the
5272    /// intermediate markdown (as many editors do).
5273    #[test]
5274    fn round_trip_empty_task_items_stripped_trailing_spaces() {
5275        let json = r#"{
5276            "version": 1,
5277            "type": "doc",
5278            "content": [{
5279                "type": "taskList",
5280                "attrs": {"localId": "abc"},
5281                "content": [
5282                    {"type": "taskItem", "attrs": {"localId": "def", "state": "TODO"}},
5283                    {"type": "taskItem", "attrs": {"localId": "ghi", "state": "DONE"}}
5284                ]
5285            }]
5286        }"#;
5287        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5288        let md = adf_to_markdown(&doc).unwrap();
5289        let stripped: String = md.lines().map(str::trim_end).collect::<Vec<_>>().join("\n");
5290        let parsed = markdown_to_adf(&stripped).unwrap();
5291        assert_eq!(parsed.content[0].node_type, "taskList");
5292        let items = parsed.content[0].content.as_ref().unwrap();
5293        assert_eq!(items.len(), 2);
5294        assert_eq!(items[0].node_type, "taskItem");
5295        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5296        assert_eq!(items[1].node_type, "taskItem");
5297        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5298    }
5299
5300    #[test]
5301    fn try_parse_task_marker_accepts_bare_checkbox() {
5302        assert_eq!(try_parse_task_marker("[ ]"), Some(("TODO", "")));
5303        assert_eq!(try_parse_task_marker("[x]"), Some(("DONE", "")));
5304        assert_eq!(try_parse_task_marker("[X]"), Some(("DONE", "")));
5305        assert_eq!(try_parse_task_marker("[ ] foo"), Some(("TODO", "foo")));
5306        assert_eq!(try_parse_task_marker("[x] foo"), Some(("DONE", "foo")));
5307        assert_eq!(try_parse_task_marker("[ ]foo"), None);
5308        assert_eq!(try_parse_task_marker("[x]foo"), None);
5309        assert_eq!(try_parse_task_marker("[y] foo"), None);
5310    }
5311
5312    #[test]
5313    fn starts_with_task_marker_matches_parser() {
5314        // Anything `try_parse_task_marker` recognises must also be flagged
5315        // here so the renderer escapes it.
5316        assert!(starts_with_task_marker("[ ]"));
5317        assert!(starts_with_task_marker("[x]"));
5318        assert!(starts_with_task_marker("[X]"));
5319        assert!(starts_with_task_marker("[ ] foo"));
5320        assert!(starts_with_task_marker("[x] foo\n"));
5321        assert!(starts_with_task_marker("[ ]\n"));
5322        // No collision when the bracket is followed by non-whitespace.
5323        assert!(!starts_with_task_marker("[ ]foo"));
5324        assert!(!starts_with_task_marker("[y] foo"));
5325        assert!(!starts_with_task_marker("foo [ ] bar"));
5326        assert!(!starts_with_task_marker(""));
5327    }
5328
5329    /// Issue #548: a `bulletList` whose item starts with literal `[ ]` text
5330    /// must round-trip through markdown without being promoted to a
5331    /// `taskList`.
5332    #[test]
5333    fn round_trip_bullet_list_with_literal_checkbox_text() {
5334        let json = r#"{
5335            "version": 1,
5336            "type": "doc",
5337            "content": [{
5338                "type": "bulletList",
5339                "content": [{
5340                    "type": "listItem",
5341                    "content": [{
5342                        "type": "paragraph",
5343                        "content": [
5344                            {"type": "text", "text": "[ ] Review the "},
5345                            {"type": "text", "text": "config.yaml", "marks": [{"type": "code"}]},
5346                            {"type": "text", "text": " file"}
5347                        ]
5348                    }]
5349                }]
5350            }]
5351        }"#;
5352        let original: AdfDocument = serde_json::from_str(json).unwrap();
5353        let md = adf_to_markdown(&original).unwrap();
5354        // Renderer must escape the leading bracket.
5355        assert!(
5356            md.contains(r"- \[ ] Review the "),
5357            "rendered markdown: {md:?}"
5358        );
5359        let parsed = markdown_to_adf(&md).unwrap();
5360        assert_eq!(parsed.content[0].node_type, "bulletList");
5361        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5362        assert_eq!(item.node_type, "listItem");
5363        let para = &item.content.as_ref().unwrap()[0];
5364        assert_eq!(para.node_type, "paragraph");
5365        let text_nodes = para.content.as_ref().unwrap();
5366        assert_eq!(text_nodes[0].text.as_deref().unwrap(), "[ ] Review the ");
5367        assert_eq!(text_nodes[1].text.as_deref().unwrap(), "config.yaml");
5368        assert_eq!(text_nodes[2].text.as_deref().unwrap(), " file");
5369    }
5370
5371    /// Issue #548: the same problem with a `[x]` marker.
5372    #[test]
5373    fn round_trip_bullet_list_with_literal_done_checkbox_text() {
5374        let json = r#"{
5375            "version": 1,
5376            "type": "doc",
5377            "content": [{
5378                "type": "bulletList",
5379                "content": [{
5380                    "type": "listItem",
5381                    "content": [{
5382                        "type": "paragraph",
5383                        "content": [{"type": "text", "text": "[x] not actually done"}]
5384                    }]
5385                }]
5386            }]
5387        }"#;
5388        let original: AdfDocument = serde_json::from_str(json).unwrap();
5389        let md = adf_to_markdown(&original).unwrap();
5390        assert!(md.contains(r"- \[x] "), "rendered markdown: {md:?}");
5391        let parsed = markdown_to_adf(&md).unwrap();
5392        assert_eq!(parsed.content[0].node_type, "bulletList");
5393        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5394        let para = &item.content.as_ref().unwrap()[0];
5395        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5396        assert_eq!(text, "[x] not actually done");
5397    }
5398
5399    /// Issue #548: `bulletList` item whose entire content is literal `[ ]`.
5400    #[test]
5401    fn round_trip_bullet_list_with_bare_literal_checkbox() {
5402        let json = r#"{
5403            "version": 1,
5404            "type": "doc",
5405            "content": [{
5406                "type": "bulletList",
5407                "content": [{
5408                    "type": "listItem",
5409                    "content": [{
5410                        "type": "paragraph",
5411                        "content": [{"type": "text", "text": "[ ]"}]
5412                    }]
5413                }]
5414            }]
5415        }"#;
5416        let original: AdfDocument = serde_json::from_str(json).unwrap();
5417        let md = adf_to_markdown(&original).unwrap();
5418        let parsed = markdown_to_adf(&md).unwrap();
5419        assert_eq!(parsed.content[0].node_type, "bulletList");
5420        let item = &parsed.content[0].content.as_ref().unwrap()[0];
5421        let para = &item.content.as_ref().unwrap()[0];
5422        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5423        assert_eq!(text, "[ ]");
5424    }
5425
5426    /// Issue #548: a `bulletList` with a non-task `[?]` prefix should not be
5427    /// escaped — that would just produce noise.
5428    #[test]
5429    fn bullet_list_non_task_bracket_text_not_escaped() {
5430        let json = r#"{
5431            "version": 1,
5432            "type": "doc",
5433            "content": [{
5434                "type": "bulletList",
5435                "content": [{
5436                    "type": "listItem",
5437                    "content": [{
5438                        "type": "paragraph",
5439                        "content": [{"type": "text", "text": "[?] unsure"}]
5440                    }]
5441                }]
5442            }]
5443        }"#;
5444        let original: AdfDocument = serde_json::from_str(json).unwrap();
5445        let md = adf_to_markdown(&original).unwrap();
5446        assert!(!md.contains(r"\["), "should not escape: {md:?}");
5447        assert!(md.contains("- [?] unsure"), "rendered: {md:?}");
5448    }
5449
5450    /// Issue #548: nested `bulletList` items inside another `bulletList`
5451    /// must also have their literal `[ ]` text escaped.
5452    #[test]
5453    fn round_trip_nested_bullet_list_with_literal_checkbox_text() {
5454        let json = r#"{
5455            "version": 1,
5456            "type": "doc",
5457            "content": [{
5458                "type": "bulletList",
5459                "content": [{
5460                    "type": "listItem",
5461                    "content": [
5462                        {"type": "paragraph", "content": [{"type": "text", "text": "outer"}]},
5463                        {"type": "bulletList", "content": [{
5464                            "type": "listItem",
5465                            "content": [{
5466                                "type": "paragraph",
5467                                "content": [{"type": "text", "text": "[ ] inner literal"}]
5468                            }]
5469                        }]}
5470                    ]
5471                }]
5472            }]
5473        }"#;
5474        let original: AdfDocument = serde_json::from_str(json).unwrap();
5475        let md = adf_to_markdown(&original).unwrap();
5476        let parsed = markdown_to_adf(&md).unwrap();
5477        let outer = &parsed.content[0];
5478        assert_eq!(outer.node_type, "bulletList");
5479        let outer_item = &outer.content.as_ref().unwrap()[0];
5480        let inner_list = &outer_item.content.as_ref().unwrap()[1];
5481        assert_eq!(inner_list.node_type, "bulletList");
5482        let inner_item = &inner_list.content.as_ref().unwrap()[0];
5483        assert_eq!(inner_item.node_type, "listItem");
5484        let para = &inner_item.content.as_ref().unwrap()[0];
5485        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
5486        assert_eq!(text, "[ ] inner literal");
5487    }
5488
5489    #[test]
5490    fn adf_task_list_to_markdown() {
5491        let doc = AdfDocument {
5492            version: 1,
5493            doc_type: "doc".to_string(),
5494            content: vec![AdfNode::task_list(vec![
5495                AdfNode::task_item(
5496                    "TODO",
5497                    vec![AdfNode::paragraph(vec![AdfNode::text("Todo")])],
5498                ),
5499                AdfNode::task_item(
5500                    "DONE",
5501                    vec![AdfNode::paragraph(vec![AdfNode::text("Done")])],
5502                ),
5503            ])],
5504        };
5505        let md = adf_to_markdown(&doc).unwrap();
5506        assert!(md.contains("- [ ] Todo"));
5507        assert!(md.contains("- [x] Done"));
5508    }
5509
5510    #[test]
5511    fn round_trip_task_list() {
5512        let md = "- [ ] Todo item\n- [x] Done item\n";
5513        let doc = markdown_to_adf(md).unwrap();
5514        let result = adf_to_markdown(&doc).unwrap();
5515        assert!(result.contains("- [ ] Todo item"));
5516        assert!(result.contains("- [x] Done item"));
5517    }
5518
5519    /// Issue #408: taskItem content with inline nodes directly (no paragraph wrapper).
5520    #[test]
5521    fn adf_task_item_unwrapped_inline_content() {
5522        // Real Confluence ADF: taskItem contains text nodes directly, no paragraph.
5523        let json = r#"{
5524            "version": 1,
5525            "type": "doc",
5526            "content": [{
5527                "type": "taskList",
5528                "attrs": {"localId": "list-001"},
5529                "content": [{
5530                    "type": "taskItem",
5531                    "attrs": {"localId": "task-001", "state": "TODO"},
5532                    "content": [{"type": "text", "text": "Do something"}]
5533                }]
5534            }]
5535        }"#;
5536        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5537        let md = adf_to_markdown(&doc).unwrap();
5538        assert!(md.contains("- [ ] Do something"), "got: {md}");
5539        assert!(!md.contains("adf-unsupported"), "got: {md}");
5540    }
5541
5542    /// Issue #408: multiple taskItems with unwrapped inline content.
5543    #[test]
5544    fn adf_task_list_multiple_unwrapped_items() {
5545        let json = r#"{
5546            "version": 1,
5547            "type": "doc",
5548            "content": [{
5549                "type": "taskList",
5550                "attrs": {"localId": "list-001"},
5551                "content": [
5552                    {
5553                        "type": "taskItem",
5554                        "attrs": {"localId": "task-001", "state": "TODO"},
5555                        "content": [{"type": "text", "text": "First task"}]
5556                    },
5557                    {
5558                        "type": "taskItem",
5559                        "attrs": {"localId": "task-002", "state": "DONE"},
5560                        "content": [{"type": "text", "text": "Second task"}]
5561                    }
5562                ]
5563            }]
5564        }"#;
5565        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5566        let md = adf_to_markdown(&doc).unwrap();
5567        assert!(md.contains("- [ ] First task"), "got: {md}");
5568        assert!(md.contains("- [x] Second task"), "got: {md}");
5569        assert!(!md.contains("adf-unsupported"), "got: {md}");
5570    }
5571
5572    /// Issue #408: unwrapped inline content with marks (bold text).
5573    #[test]
5574    fn adf_task_item_unwrapped_inline_with_marks() {
5575        let json = r#"{
5576            "version": 1,
5577            "type": "doc",
5578            "content": [{
5579                "type": "taskList",
5580                "attrs": {"localId": "list-001"},
5581                "content": [{
5582                    "type": "taskItem",
5583                    "attrs": {"localId": "task-001", "state": "TODO"},
5584                    "content": [
5585                        {"type": "text", "text": "Buy "},
5586                        {"type": "text", "text": "groceries", "marks": [{"type": "strong"}]},
5587                        {"type": "text", "text": " today"}
5588                    ]
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("- [ ] Buy **groceries** today"), "got: {md}");
5595    }
5596
5597    /// Issue #408: taskItem localId is preserved for unwrapped inline content.
5598    #[test]
5599    fn adf_task_item_unwrapped_preserves_local_id() {
5600        let json = r#"{
5601            "version": 1,
5602            "type": "doc",
5603            "content": [{
5604                "type": "taskList",
5605                "attrs": {"localId": "list-001"},
5606                "content": [{
5607                    "type": "taskItem",
5608                    "attrs": {"localId": "task-001", "state": "TODO"},
5609                    "content": [{"type": "text", "text": "Do something"}]
5610                }]
5611            }]
5612        }"#;
5613        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5614        let md = adf_to_markdown(&doc).unwrap();
5615        assert!(md.contains("{localId=task-001}"), "got: {md}");
5616        assert!(md.contains("{localId=list-001}"), "got: {md}");
5617    }
5618
5619    /// Issue #408: round-trip from Confluence ADF with unwrapped taskItem content.
5620    #[test]
5621    fn round_trip_task_list_unwrapped_inline() {
5622        let json = r#"{
5623            "version": 1,
5624            "type": "doc",
5625            "content": [{
5626                "type": "taskList",
5627                "attrs": {"localId": "list-001"},
5628                "content": [
5629                    {
5630                        "type": "taskItem",
5631                        "attrs": {"localId": "task-001", "state": "TODO"},
5632                        "content": [{"type": "text", "text": "Do something"}]
5633                    },
5634                    {
5635                        "type": "taskItem",
5636                        "attrs": {"localId": "task-002", "state": "DONE"},
5637                        "content": [{"type": "text", "text": "Already done"}]
5638                    }
5639                ]
5640            }]
5641        }"#;
5642        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5643        let md = adf_to_markdown(&doc).unwrap();
5644
5645        // Round-trip: markdown back to ADF
5646        let doc2 = markdown_to_adf(&md).unwrap();
5647        assert_eq!(doc2.content[0].node_type, "taskList");
5648
5649        let items = doc2.content[0].content.as_ref().unwrap();
5650        assert_eq!(items.len(), 2);
5651        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5652        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
5653
5654        // localIds preserved
5655        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5656        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "task-002");
5657        assert_eq!(
5658            doc2.content[0].attrs.as_ref().unwrap()["localId"],
5659            "list-001"
5660        );
5661    }
5662
5663    /// Issue #408: taskItem with inline content followed by a nested block (sub-list).
5664    #[test]
5665    fn adf_task_item_unwrapped_inline_then_block() {
5666        let json = r#"{
5667            "version": 1,
5668            "type": "doc",
5669            "content": [{
5670                "type": "taskList",
5671                "attrs": {"localId": "list-001"},
5672                "content": [{
5673                    "type": "taskItem",
5674                    "attrs": {"localId": "task-001", "state": "TODO"},
5675                    "content": [
5676                        {"type": "text", "text": "Parent task"},
5677                        {
5678                            "type": "bulletList",
5679                            "content": [{
5680                                "type": "listItem",
5681                                "content": [{
5682                                    "type": "paragraph",
5683                                    "content": [{"type": "text", "text": "sub-item"}]
5684                                }]
5685                            }]
5686                        }
5687                    ]
5688                }]
5689            }]
5690        }"#;
5691        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5692        let md = adf_to_markdown(&doc).unwrap();
5693        assert!(md.contains("- [ ] Parent task"), "got: {md}");
5694        assert!(md.contains("  - sub-item"), "got: {md}");
5695        assert!(!md.contains("adf-unsupported"), "got: {md}");
5696    }
5697
5698    /// Issue #408: taskItem with empty content array renders without panic.
5699    #[test]
5700    fn adf_task_item_empty_content() {
5701        let json = r#"{
5702            "version": 1,
5703            "type": "doc",
5704            "content": [{
5705                "type": "taskList",
5706                "attrs": {"localId": "list-001"},
5707                "content": [{
5708                    "type": "taskItem",
5709                    "attrs": {"localId": "task-001", "state": "TODO"},
5710                    "content": []
5711                }]
5712            }]
5713        }"#;
5714        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5715        let md = adf_to_markdown(&doc).unwrap();
5716        assert!(md.contains("- [ ] "), "got: {md}");
5717        assert!(!md.contains("adf-unsupported"), "got: {md}");
5718    }
5719
5720    /// Issue #489: nested taskItem inside taskItem.content renders as indented
5721    /// task items instead of corrupting the surrounding taskList.
5722    #[test]
5723    fn adf_nested_task_item_renders_without_corruption() {
5724        let json = r#"{
5725            "type": "doc",
5726            "version": 1,
5727            "content": [{
5728                "type": "taskList",
5729                "attrs": {"localId": ""},
5730                "content": [
5731                    {
5732                        "type": "taskItem",
5733                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-aabbccdd1234", "state": "TODO"},
5734                        "content": [{"type": "text", "text": "Normal task"}]
5735                    },
5736                    {
5737                        "type": "taskItem",
5738                        "attrs": {"localId": ""},
5739                        "content": [
5740                            {
5741                                "type": "taskItem",
5742                                "attrs": {"localId": "bbccddee-2345-6789-bcde-bbccddee2345", "state": "TODO"},
5743                                "content": [{"type": "text", "text": "Nested task one"}]
5744                            },
5745                            {
5746                                "type": "taskItem",
5747                                "attrs": {"localId": "ccddee11-3456-7890-cdef-ccddee113456", "state": "DONE"},
5748                                "content": [{"type": "text", "text": "Nested task two"}]
5749                            }
5750                        ]
5751                    }
5752                ]
5753            }]
5754        }"#;
5755        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5756        let md = adf_to_markdown(&doc).unwrap();
5757        // Normal task preserved
5758        assert!(md.contains("- [ ] Normal task"), "got: {md}");
5759        // Nested tasks rendered as indented task items, not adf-unsupported
5760        assert!(!md.contains("adf-unsupported"), "got: {md}");
5761        assert!(md.contains("  - [ ] Nested task one"), "got: {md}");
5762        assert!(md.contains("  - [x] Nested task two"), "got: {md}");
5763    }
5764
5765    /// Issue #489: round-trip of nested taskItem preserves data.
5766    #[test]
5767    fn round_trip_nested_task_item() {
5768        let json = r#"{
5769            "type": "doc",
5770            "version": 1,
5771            "content": [{
5772                "type": "taskList",
5773                "attrs": {"localId": ""},
5774                "content": [
5775                    {
5776                        "type": "taskItem",
5777                        "attrs": {"localId": "task-001", "state": "TODO"},
5778                        "content": [{"type": "text", "text": "Normal task"}]
5779                    },
5780                    {
5781                        "type": "taskItem",
5782                        "attrs": {"localId": ""},
5783                        "content": [
5784                            {
5785                                "type": "taskItem",
5786                                "attrs": {"localId": "task-002", "state": "TODO"},
5787                                "content": [{"type": "text", "text": "Nested one"}]
5788                            },
5789                            {
5790                                "type": "taskItem",
5791                                "attrs": {"localId": "task-003", "state": "DONE"},
5792                                "content": [{"type": "text", "text": "Nested two"}]
5793                            }
5794                        ]
5795                    }
5796                ]
5797            }]
5798        }"#;
5799        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5800        let md = adf_to_markdown(&doc).unwrap();
5801        let doc2 = markdown_to_adf(&md).unwrap();
5802
5803        // Top-level structure: taskList with 2 items
5804        assert_eq!(doc2.content[0].node_type, "taskList");
5805        let items = doc2.content[0].content.as_ref().unwrap();
5806        assert_eq!(items.len(), 2, "expected 2 top-level items, got: {items:?}");
5807
5808        // First item: normal task preserved
5809        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
5810        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "task-001");
5811        let first_content = items[0].content.as_ref().unwrap();
5812        assert_eq!(first_content[0].text.as_deref(), Some("Normal task"));
5813
5814        // Second item: container taskItem — no spurious `state` attr
5815        let container = &items[1];
5816        assert_eq!(container.node_type, "taskItem");
5817        let c_attrs = container.attrs.as_ref().unwrap();
5818        assert!(
5819            c_attrs.get("state").is_none(),
5820            "container should have no state attr, got: {c_attrs:?}"
5821        );
5822
5823        // Children are bare taskItems, NOT wrapped in a taskList
5824        let container_content = container.content.as_ref().unwrap();
5825        assert_eq!(
5826            container_content.len(),
5827            2,
5828            "expected 2 bare taskItem children"
5829        );
5830        assert_eq!(container_content[0].node_type, "taskItem");
5831        assert_eq!(
5832            container_content[0].attrs.as_ref().unwrap()["state"],
5833            "TODO"
5834        );
5835        assert_eq!(
5836            container_content[0].attrs.as_ref().unwrap()["localId"],
5837            "task-002"
5838        );
5839        assert_eq!(container_content[1].node_type, "taskItem");
5840        assert_eq!(
5841            container_content[1].attrs.as_ref().unwrap()["state"],
5842            "DONE"
5843        );
5844        assert_eq!(
5845            container_content[1].attrs.as_ref().unwrap()["localId"],
5846            "task-003"
5847        );
5848    }
5849
5850    /// Issue #489: nested taskItem with localIds on both container and children.
5851    #[test]
5852    fn adf_nested_task_item_preserves_local_ids() {
5853        let json = r#"{
5854            "type": "doc",
5855            "version": 1,
5856            "content": [{
5857                "type": "taskList",
5858                "attrs": {"localId": "list-001"},
5859                "content": [{
5860                    "type": "taskItem",
5861                    "attrs": {"localId": "container-001", "state": "TODO"},
5862                    "content": [{
5863                        "type": "taskItem",
5864                        "attrs": {"localId": "child-001", "state": "DONE"},
5865                        "content": [{"type": "text", "text": "Nested child"}]
5866                    }]
5867                }]
5868            }]
5869        }"#;
5870        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5871        let md = adf_to_markdown(&doc).unwrap();
5872        // Container localId is emitted
5873        assert!(
5874            md.contains("localId=container-001"),
5875            "container localId missing: {md}"
5876        );
5877        // Child localId is emitted
5878        assert!(
5879            md.contains("localId=child-001"),
5880            "child localId missing: {md}"
5881        );
5882        assert!(!md.contains("adf-unsupported"), "got: {md}");
5883    }
5884
5885    /// Issue #489: nested taskItem content mixed with a non-taskItem block node.
5886    /// Covers the else branch in the renderer where a child is not a taskItem.
5887    #[test]
5888    fn adf_nested_task_item_mixed_with_block_node() {
5889        let json = r#"{
5890            "type": "doc",
5891            "version": 1,
5892            "content": [{
5893                "type": "taskList",
5894                "attrs": {"localId": ""},
5895                "content": [{
5896                    "type": "taskItem",
5897                    "attrs": {"localId": "", "state": "TODO"},
5898                    "content": [
5899                        {
5900                            "type": "taskItem",
5901                            "attrs": {"localId": "", "state": "TODO"},
5902                            "content": [{"type": "text", "text": "A nested task"}]
5903                        },
5904                        {
5905                            "type": "paragraph",
5906                            "content": [{"type": "text", "text": "Stray paragraph"}]
5907                        }
5908                    ]
5909                }]
5910            }]
5911        }"#;
5912        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5913        let md = adf_to_markdown(&doc).unwrap();
5914        assert!(md.contains("  - [ ] A nested task"), "got: {md}");
5915        assert!(md.contains("  Stray paragraph"), "got: {md}");
5916        assert!(!md.contains("adf-unsupported"), "got: {md}");
5917    }
5918
5919    /// Issue #489: task item with inline text AND indented sub-content.
5920    /// Covers the parser's `Some` branch when appending nested blocks to
5921    /// an existing content vec.
5922    #[test]
5923    fn task_item_with_text_and_nested_sub_content() {
5924        let md = "- [ ] Parent task\n  - [ ] Sub task\n";
5925        let doc = markdown_to_adf(md).unwrap();
5926        assert_eq!(doc.content[0].node_type, "taskList");
5927        let items = doc.content[0].content.as_ref().unwrap();
5928        // Issue #506: the nested taskList is a sibling of the taskItem,
5929        // not a child — matching ADF's canonical structure.
5930        assert_eq!(items.len(), 2, "got: {items:?}");
5931        let parent = &items[0];
5932        assert_eq!(parent.attrs.as_ref().unwrap()["state"], "TODO");
5933        let parent_content = parent.content.as_ref().unwrap();
5934        assert_eq!(parent_content[0].text.as_deref(), Some("Parent task"));
5935        // Second item: nested taskList (sibling)
5936        assert_eq!(items[1].node_type, "taskList");
5937        let nested = items[1].content.as_ref().unwrap();
5938        assert_eq!(nested.len(), 1);
5939        assert_eq!(nested[0].attrs.as_ref().unwrap()["state"], "TODO");
5940    }
5941
5942    /// Issue #489: empty task item with non-taskList sub-content (e.g. a
5943    /// paragraph).  Exercises the `None` branch when the sub-content does
5944    /// not qualify for container-unwrap.
5945    #[test]
5946    fn task_item_empty_with_non_tasklist_sub_content() {
5947        let md = "- [ ] \n  Some paragraph text\n";
5948        let doc = markdown_to_adf(md).unwrap();
5949        assert_eq!(doc.content[0].node_type, "taskList");
5950        let items = doc.content[0].content.as_ref().unwrap();
5951        assert_eq!(items.len(), 1);
5952        let item = &items[0];
5953        assert_eq!(item.attrs.as_ref().unwrap()["state"], "TODO");
5954        let content = item.content.as_ref().unwrap();
5955        // Sub-content is a paragraph (not unwrapped since it's not a taskList)
5956        assert_eq!(content[0].node_type, "paragraph");
5957    }
5958
5959    /// Issue #489: single nested taskItem (edge case — only one child).
5960    #[test]
5961    fn adf_nested_task_item_single_child() {
5962        let json = r#"{
5963            "type": "doc",
5964            "version": 1,
5965            "content": [{
5966                "type": "taskList",
5967                "attrs": {"localId": ""},
5968                "content": [{
5969                    "type": "taskItem",
5970                    "attrs": {"localId": "", "state": "TODO"},
5971                    "content": [{
5972                        "type": "taskItem",
5973                        "attrs": {"localId": "", "state": "DONE"},
5974                        "content": [{"type": "text", "text": "Only child"}]
5975                    }]
5976                }]
5977            }]
5978        }"#;
5979        let doc: AdfDocument = serde_json::from_str(json).unwrap();
5980        let md = adf_to_markdown(&doc).unwrap();
5981        assert!(md.contains("  - [x] Only child"), "got: {md}");
5982        assert!(!md.contains("adf-unsupported"), "got: {md}");
5983    }
5984
5985    /// Issue #506: nested taskList as direct child of outer taskList is
5986    /// rendered indented so it round-trips back as taskList, not taskItem.
5987    #[test]
5988    fn adf_nested_tasklist_sibling_renders_indented() {
5989        let json = r#"{
5990            "version": 1,
5991            "type": "doc",
5992            "content": [{
5993                "type": "taskList",
5994                "attrs": {"localId": ""},
5995                "content": [
5996                    {
5997                        "type": "taskItem",
5998                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000001", "state": "TODO"},
5999                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6000                    },
6001                    {
6002                        "type": "taskList",
6003                        "attrs": {"localId": ""},
6004                        "content": [{
6005                            "type": "taskItem",
6006                            "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000002", "state": "TODO"},
6007                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6008                        }]
6009                    },
6010                    {
6011                        "type": "taskItem",
6012                        "attrs": {"localId": "aabbccdd-1234-5678-abcd-000000000003", "state": "TODO"},
6013                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6014                    }
6015                ]
6016            }]
6017        }"#;
6018        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6019        let md = adf_to_markdown(&doc).unwrap();
6020        // The nested taskList should be indented under the preceding item.
6021        assert!(md.contains("- [ ] parent task one"), "got: {md}");
6022        assert!(md.contains("  - [ ] nested sub-task"), "got: {md}");
6023        assert!(md.contains("- [ ] parent task two"), "got: {md}");
6024    }
6025
6026    /// Issue #506: round-trip preserves nested taskList type.
6027    #[test]
6028    fn round_trip_nested_tasklist_preserves_type() {
6029        let json = r#"{
6030            "version": 1,
6031            "type": "doc",
6032            "content": [{
6033                "type": "taskList",
6034                "attrs": {"localId": ""},
6035                "content": [
6036                    {
6037                        "type": "taskItem",
6038                        "attrs": {"localId": "", "state": "TODO"},
6039                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task one"}]}]
6040                    },
6041                    {
6042                        "type": "taskList",
6043                        "attrs": {"localId": ""},
6044                        "content": [{
6045                            "type": "taskItem",
6046                            "attrs": {"localId": "", "state": "TODO"},
6047                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "nested sub-task"}]}]
6048                        }]
6049                    },
6050                    {
6051                        "type": "taskItem",
6052                        "attrs": {"localId": "", "state": "TODO"},
6053                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent task two"}]}]
6054                    }
6055                ]
6056            }]
6057        }"#;
6058        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6059        let md = adf_to_markdown(&doc).unwrap();
6060        let rt_doc = markdown_to_adf(&md).unwrap();
6061        // The outer taskList should still be present.
6062        assert_eq!(rt_doc.content[0].node_type, "taskList");
6063        let items = rt_doc.content[0].content.as_ref().unwrap();
6064        // The nested taskList is a sibling of the taskItem nodes,
6065        // matching the original ADF structure (issue #506).
6066        assert_eq!(items.len(), 3, "got: {items:?}");
6067        assert_eq!(items[0].node_type, "taskItem");
6068        assert_eq!(
6069            items[1].node_type, "taskList",
6070            "nested taskList should survive round-trip"
6071        );
6072        assert_eq!(items[2].node_type, "taskItem");
6073        let nested_items = items[1].content.as_ref().unwrap();
6074        assert_eq!(nested_items[0].attrs.as_ref().unwrap()["state"], "TODO");
6075    }
6076
6077    /// Issue #506: nested taskList with DONE state preserves checkbox.
6078    #[test]
6079    fn adf_nested_tasklist_done_state() {
6080        let json = r#"{
6081            "version": 1,
6082            "type": "doc",
6083            "content": [{
6084                "type": "taskList",
6085                "attrs": {"localId": ""},
6086                "content": [
6087                    {
6088                        "type": "taskItem",
6089                        "attrs": {"localId": "", "state": "TODO"},
6090                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6091                    },
6092                    {
6093                        "type": "taskList",
6094                        "attrs": {"localId": ""},
6095                        "content": [{
6096                            "type": "taskItem",
6097                            "attrs": {"localId": "", "state": "DONE"},
6098                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "done child"}]}]
6099                        }]
6100                    }
6101                ]
6102            }]
6103        }"#;
6104        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6105        let md = adf_to_markdown(&doc).unwrap();
6106        assert!(md.contains("  - [x] done child"), "got: {md}");
6107        // Round-trip preserves DONE state — nested taskList is a sibling.
6108        let rt_doc = markdown_to_adf(&md).unwrap();
6109        let items = rt_doc.content[0].content.as_ref().unwrap();
6110        assert_eq!(
6111            items[1].node_type, "taskList",
6112            "nested taskList should survive round-trip"
6113        );
6114        let nested_item = &items[1].content.as_ref().unwrap()[0];
6115        assert_eq!(nested_item.attrs.as_ref().unwrap()["state"], "DONE");
6116    }
6117
6118    /// Issue #506: multiple nested taskLists at the same level.
6119    #[test]
6120    fn adf_multiple_nested_tasklists() {
6121        let json = r#"{
6122            "version": 1,
6123            "type": "doc",
6124            "content": [{
6125                "type": "taskList",
6126                "attrs": {"localId": ""},
6127                "content": [
6128                    {
6129                        "type": "taskItem",
6130                        "attrs": {"localId": "", "state": "TODO"},
6131                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "first parent"}]}]
6132                    },
6133                    {
6134                        "type": "taskList",
6135                        "attrs": {"localId": ""},
6136                        "content": [{
6137                            "type": "taskItem",
6138                            "attrs": {"localId": "", "state": "TODO"},
6139                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child A"}]}]
6140                        }]
6141                    },
6142                    {
6143                        "type": "taskItem",
6144                        "attrs": {"localId": "", "state": "TODO"},
6145                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "second parent"}]}]
6146                    },
6147                    {
6148                        "type": "taskList",
6149                        "attrs": {"localId": ""},
6150                        "content": [{
6151                            "type": "taskItem",
6152                            "attrs": {"localId": "", "state": "DONE"},
6153                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child B"}]}]
6154                        }]
6155                    }
6156                ]
6157            }]
6158        }"#;
6159        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6160        let md = adf_to_markdown(&doc).unwrap();
6161        assert!(md.contains("- [ ] first parent"), "got: {md}");
6162        assert!(md.contains("  - [ ] child A"), "got: {md}");
6163        assert!(md.contains("- [ ] second parent"), "got: {md}");
6164        assert!(md.contains("  - [x] child B"), "got: {md}");
6165    }
6166
6167    /// Issue #506: second round-trip is stable (idempotent after first
6168    /// structural normalisation).
6169    #[test]
6170    fn round_trip_nested_tasklist_stable() {
6171        let json = r#"{
6172            "version": 1,
6173            "type": "doc",
6174            "content": [{
6175                "type": "taskList",
6176                "attrs": {"localId": ""},
6177                "content": [
6178                    {
6179                        "type": "taskItem",
6180                        "attrs": {"localId": "", "state": "TODO"},
6181                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "parent"}]}]
6182                    },
6183                    {
6184                        "type": "taskList",
6185                        "attrs": {"localId": ""},
6186                        "content": [{
6187                            "type": "taskItem",
6188                            "attrs": {"localId": "", "state": "TODO"},
6189                            "content": [{"type": "paragraph", "content": [{"type": "text", "text": "child"}]}]
6190                        }]
6191                    }
6192                ]
6193            }]
6194        }"#;
6195        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6196        // First round-trip.
6197        let md1 = adf_to_markdown(&doc).unwrap();
6198        let rt1 = markdown_to_adf(&md1).unwrap();
6199        // Second round-trip.
6200        let md2 = adf_to_markdown(&rt1).unwrap();
6201        let rt2 = markdown_to_adf(&md2).unwrap();
6202        // Markdown output should be identical after first normalisation.
6203        assert_eq!(md1, md2, "markdown should be stable across round-trips");
6204        // ADF structure should also be stable.
6205        let rt1_json = serde_json::to_string(&rt1).unwrap();
6206        let rt2_json = serde_json::to_string(&rt2).unwrap();
6207        assert_eq!(
6208            rt1_json, rt2_json,
6209            "ADF should be stable across round-trips"
6210        );
6211    }
6212
6213    /// Issue #506: task item with text and mixed indented sub-content
6214    /// (taskList + non-taskList block).  Exercises the `child_nodes` branch
6215    /// where non-taskList blocks stay as children of the taskItem while
6216    /// taskLists are promoted to siblings.
6217    #[test]
6218    fn task_item_mixed_sub_content_splits_siblings() {
6219        let md = "- [ ] Parent task\n  - [ ] Sub task\n  Some paragraph\n";
6220        let doc = markdown_to_adf(md).unwrap();
6221        let items = doc.content[0].content.as_ref().unwrap();
6222        // taskItem + 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        // Inline text + paragraph block (the non-taskList sub-content)
6227        assert!(
6228            parent_content.iter().any(|n| n.node_type == "paragraph"),
6229            "non-taskList sub-content should stay as child: {parent_content:?}"
6230        );
6231        // Sibling taskList
6232        assert_eq!(items[1].node_type, "taskList");
6233    }
6234
6235    /// Issue #506: empty task item with mixed indented sub-content hits the
6236    /// `None` arm of the `task.content` match when promoting taskLists to
6237    /// siblings.
6238    #[test]
6239    fn empty_task_item_mixed_sub_content_none_arm() {
6240        let md = "- [ ] \n  Some paragraph\n  - [ ] Sub task\n";
6241        let doc = markdown_to_adf(md).unwrap();
6242        let items = doc.content[0].content.as_ref().unwrap();
6243        // taskItem (with paragraph child) + sibling taskList
6244        assert_eq!(items.len(), 2, "got: {items:?}");
6245        assert_eq!(items[0].node_type, "taskItem");
6246        let parent_content = items[0].content.as_ref().unwrap();
6247        assert!(
6248            parent_content.iter().any(|n| n.node_type == "paragraph"),
6249            "paragraph should be assigned to taskItem: {parent_content:?}"
6250        );
6251        assert_eq!(items[1].node_type, "taskList");
6252    }
6253
6254    /// Issue #506: task item with text and only non-taskList sub-content
6255    /// (no sibling taskLists).  Exercises the fall-through path where
6256    /// `sibling_task_lists` is empty and child_nodes are appended to
6257    /// the existing task content (Some arm).
6258    #[test]
6259    fn task_item_text_with_non_tasklist_sub_content_only() {
6260        let md = "- [ ] My task\n  Extra paragraph content\n";
6261        let doc = markdown_to_adf(md).unwrap();
6262        let items = doc.content[0].content.as_ref().unwrap();
6263        // Single taskItem — no sibling taskLists to extract.
6264        assert_eq!(items.len(), 1, "got: {items:?}");
6265        assert_eq!(items[0].node_type, "taskItem");
6266        let content = items[0].content.as_ref().unwrap();
6267        // Inline text + sub-paragraph
6268        assert!(
6269            content.iter().any(|n| n.node_type == "paragraph"),
6270            "paragraph sub-content should be a child of taskItem: {content:?}"
6271        );
6272    }
6273
6274    /// Covers the else branch in render_list_item_content where the first
6275    /// child of a list item is a block node (not paragraph, not inline).
6276    #[test]
6277    fn adf_list_item_leading_block_node() {
6278        let json = r#"{
6279            "version": 1,
6280            "type": "doc",
6281            "content": [{
6282                "type": "bulletList",
6283                "content": [{
6284                    "type": "listItem",
6285                    "content": [{
6286                        "type": "codeBlock",
6287                        "attrs": {"language": "rust"},
6288                        "content": [{"type": "text", "text": "let x = 1;"}]
6289                    }]
6290                }]
6291            }]
6292        }"#;
6293        let doc: AdfDocument = serde_json::from_str(json).unwrap();
6294        let md = adf_to_markdown(&doc).unwrap();
6295        assert!(md.contains("```rust"), "got: {md}");
6296        assert!(md.contains("let x = 1;"), "got: {md}");
6297        // Continuation lines must be indented so the block stays inside
6298        // the list item on round-trip (issue #511).
6299        for line in md.lines() {
6300            if line.starts_with("- ") {
6301                continue; // first line with list marker
6302            }
6303            if line.trim().is_empty() {
6304                continue;
6305            }
6306            assert!(
6307                line.starts_with("  "),
6308                "continuation line not indented: {line:?}"
6309            );
6310        }
6311    }
6312
6313    /// Round-trip a codeBlock inside a listItem whose content contains a
6314    /// backtick character — the exact reproducer from issue #511.
6315    #[test]
6316    fn code_block_in_list_item_backtick_roundtrip() {
6317        let json = r#"{
6318            "version": 1,
6319            "type": "doc",
6320            "content": [{
6321                "type": "bulletList",
6322                "content": [{
6323                    "type": "listItem",
6324                    "content": [{
6325                        "type": "codeBlock",
6326                        "attrs": {"language": ""},
6327                        "content": [{"type": "text", "text": "error: some value with a backtick ` at end"}]
6328                    }]
6329                }]
6330            }]
6331        }"#;
6332        let original: AdfDocument = serde_json::from_str(json).unwrap();
6333        let md = adf_to_markdown(&original).unwrap();
6334        let roundtripped = markdown_to_adf(&md).unwrap();
6335        let list = &roundtripped.content[0];
6336        assert_eq!(list.node_type, "bulletList", "top node: {}", list.node_type);
6337        let item = &list.content.as_ref().unwrap()[0];
6338        let first_child = &item.content.as_ref().unwrap()[0];
6339        assert_eq!(
6340            first_child.node_type, "codeBlock",
6341            "expected codeBlock, got: {}",
6342            first_child.node_type
6343        );
6344        let text = first_child.content.as_ref().unwrap()[0]
6345            .text
6346            .as_deref()
6347            .unwrap();
6348        assert_eq!(text, "error: some value with a backtick ` at end");
6349    }
6350
6351    /// Code block with language tag inside a list item round-trips.
6352    #[test]
6353    fn code_block_with_language_in_list_item_roundtrip() {
6354        let json = r#"{
6355            "version": 1,
6356            "type": "doc",
6357            "content": [{
6358                "type": "bulletList",
6359                "content": [{
6360                    "type": "listItem",
6361                    "content": [{
6362                        "type": "codeBlock",
6363                        "attrs": {"language": "rust"},
6364                        "content": [{"type": "text", "text": "fn main() {\n    println!(\"hello\");\n}"}]
6365                    }]
6366                }]
6367            }]
6368        }"#;
6369        let original: AdfDocument = serde_json::from_str(json).unwrap();
6370        let md = adf_to_markdown(&original).unwrap();
6371        let roundtripped = markdown_to_adf(&md).unwrap();
6372        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6373        let code = &item.content.as_ref().unwrap()[0];
6374        assert_eq!(code.node_type, "codeBlock");
6375        let lang = code
6376            .attrs
6377            .as_ref()
6378            .and_then(|a| a.get("language"))
6379            .and_then(serde_json::Value::as_str)
6380            .unwrap_or("");
6381        assert_eq!(lang, "rust");
6382        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6383        assert!(text.contains("println!"), "code content: {text}");
6384    }
6385
6386    /// Code block in an ordered list item round-trips correctly.
6387    #[test]
6388    fn code_block_in_ordered_list_item_roundtrip() {
6389        let json = r#"{
6390            "version": 1,
6391            "type": "doc",
6392            "content": [{
6393                "type": "orderedList",
6394                "attrs": {"order": 1},
6395                "content": [{
6396                    "type": "listItem",
6397                    "content": [{
6398                        "type": "codeBlock",
6399                        "attrs": {"language": ""},
6400                        "content": [{"type": "text", "text": "backtick ` here"}]
6401                    }]
6402                }]
6403            }]
6404        }"#;
6405        let original: AdfDocument = serde_json::from_str(json).unwrap();
6406        let md = adf_to_markdown(&original).unwrap();
6407        let roundtripped = markdown_to_adf(&md).unwrap();
6408        let list = &roundtripped.content[0];
6409        assert_eq!(list.node_type, "orderedList");
6410        let item = &list.content.as_ref().unwrap()[0];
6411        let code = &item.content.as_ref().unwrap()[0];
6412        assert_eq!(code.node_type, "codeBlock");
6413        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6414        assert_eq!(text, "backtick ` here");
6415    }
6416
6417    /// A list item with a code block followed by a paragraph round-trips.
6418    #[test]
6419    fn code_block_then_paragraph_in_list_item() {
6420        let json = r#"{
6421            "version": 1,
6422            "type": "doc",
6423            "content": [{
6424                "type": "bulletList",
6425                "content": [{
6426                    "type": "listItem",
6427                    "content": [
6428                        {
6429                            "type": "codeBlock",
6430                            "attrs": {"language": ""},
6431                            "content": [{"type": "text", "text": "code with ` backtick"}]
6432                        },
6433                        {
6434                            "type": "paragraph",
6435                            "content": [{"type": "text", "text": "description"}]
6436                        }
6437                    ]
6438                }]
6439            }]
6440        }"#;
6441        let original: AdfDocument = serde_json::from_str(json).unwrap();
6442        let md = adf_to_markdown(&original).unwrap();
6443        let roundtripped = markdown_to_adf(&md).unwrap();
6444        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6445        let children = item.content.as_ref().unwrap();
6446        assert_eq!(children[0].node_type, "codeBlock");
6447        assert_eq!(children[1].node_type, "paragraph");
6448    }
6449
6450    /// Multiple backticks in code block content round-trip.
6451    #[test]
6452    fn code_block_multiple_backticks_in_list_item() {
6453        let json = r#"{
6454            "version": 1,
6455            "type": "doc",
6456            "content": [{
6457                "type": "bulletList",
6458                "content": [{
6459                    "type": "listItem",
6460                    "content": [{
6461                        "type": "codeBlock",
6462                        "attrs": {"language": ""},
6463                        "content": [{"type": "text", "text": "a ` b `` c ``` d"}]
6464                    }]
6465                }]
6466            }]
6467        }"#;
6468        let original: AdfDocument = serde_json::from_str(json).unwrap();
6469        let md = adf_to_markdown(&original).unwrap();
6470        let roundtripped = markdown_to_adf(&md).unwrap();
6471        let item = &roundtripped.content[0].content.as_ref().unwrap()[0];
6472        let code = &item.content.as_ref().unwrap()[0];
6473        assert_eq!(code.node_type, "codeBlock");
6474        let text = code.content.as_ref().unwrap()[0].text.as_deref().unwrap();
6475        assert_eq!(text, "a ` b `` c ``` d");
6476    }
6477
6478    /// Media as the first child of a list item with a subsequent paragraph
6479    /// exercises the media + sub_lines branch in `parse_list_item_first_line`.
6480    #[test]
6481    fn media_first_child_with_sub_content_in_list_item() {
6482        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
6483          {"type":"listItem","content":[
6484            {"type":"mediaSingle","attrs":{"layout":"center"},
6485             "content":[{"type":"media","attrs":{"type":"file","id":"img-99","collection":"col-x","height":50,"width":100}}]},
6486            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
6487          ]}
6488        ]}]}"#;
6489        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
6490        let md = adf_to_markdown(&doc).unwrap();
6491        let rt = markdown_to_adf(&md).unwrap();
6492        let item = &rt.content[0].content.as_ref().unwrap()[0];
6493        let children = item.content.as_ref().unwrap();
6494        assert_eq!(
6495            children.len(),
6496            2,
6497            "expected 2 children, got {}",
6498            children.len()
6499        );
6500        assert_eq!(children[0].node_type, "mediaSingle");
6501        let media = &children[0].content.as_ref().unwrap()[0];
6502        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-99");
6503        assert_eq!(children[1].node_type, "paragraph");
6504    }
6505
6506    #[test]
6507    fn inline_bold() {
6508        let doc = markdown_to_adf("Some **bold** text").unwrap();
6509        let content = doc.content[0].content.as_ref().unwrap();
6510        assert!(content.len() >= 3);
6511        let bold_node = &content[1];
6512        assert_eq!(bold_node.text.as_deref(), Some("bold"));
6513        let marks = bold_node.marks.as_ref().unwrap();
6514        assert_eq!(marks[0].mark_type, "strong");
6515    }
6516
6517    #[test]
6518    fn inline_italic() {
6519        let doc = markdown_to_adf("Some *italic* text").unwrap();
6520        let content = doc.content[0].content.as_ref().unwrap();
6521        let italic_node = &content[1];
6522        assert_eq!(italic_node.text.as_deref(), Some("italic"));
6523        let marks = italic_node.marks.as_ref().unwrap();
6524        assert_eq!(marks[0].mark_type, "em");
6525    }
6526
6527    #[test]
6528    fn inline_code() {
6529        let doc = markdown_to_adf("Use `code` here").unwrap();
6530        let content = doc.content[0].content.as_ref().unwrap();
6531        let code_node = &content[1];
6532        assert_eq!(code_node.text.as_deref(), Some("code"));
6533        let marks = code_node.marks.as_ref().unwrap();
6534        assert_eq!(marks[0].mark_type, "code");
6535    }
6536
6537    /// Issue #578: a code-marked text with an internal backtick must be
6538    /// emitted using double-backtick delimiters so it round-trips as a
6539    /// single node rather than being split on the inner backtick.
6540    #[test]
6541    fn inline_code_with_backtick_emitted_with_double_delimiters() {
6542        let doc = AdfDocument {
6543            version: 1,
6544            doc_type: "doc".to_string(),
6545            content: vec![AdfNode::paragraph(vec![
6546                AdfNode::text("Run "),
6547                AdfNode::text_with_marks(
6548                    "ADD `custom_threshold` TEXT NOT NULL",
6549                    vec![AdfMark::code()],
6550                ),
6551                AdfNode::text(" to update the schema."),
6552            ])],
6553        };
6554        let md = adf_to_markdown(&doc).unwrap();
6555        assert!(
6556            md.contains("``ADD `custom_threshold` TEXT NOT NULL``"),
6557            "expected double-backtick delimiters, got: {md}"
6558        );
6559    }
6560
6561    /// Issue #578: double-backtick delimited code spans parse as a single
6562    /// code-marked text node that preserves the embedded single backticks.
6563    #[test]
6564    fn inline_code_double_backtick_delimiters_parse() {
6565        let doc = markdown_to_adf("Run ``ADD `custom_threshold` TEXT NOT NULL`` now").unwrap();
6566        let content = doc.content[0].content.as_ref().unwrap();
6567        assert_eq!(content.len(), 3, "content: {content:?}");
6568        let code_node = &content[1];
6569        assert_eq!(
6570            code_node.text.as_deref(),
6571            Some("ADD `custom_threshold` TEXT NOT NULL")
6572        );
6573        let marks = code_node.marks.as_ref().unwrap();
6574        assert_eq!(marks[0].mark_type, "code");
6575    }
6576
6577    /// Issue #578: the full reproducer — a code-marked text with inner
6578    /// backticks survives ADF → JFM → ADF round-trip intact.
6579    #[test]
6580    fn inline_code_with_backtick_roundtrip() {
6581        let json = r#"{
6582            "version": 1,
6583            "type": "doc",
6584            "content": [{
6585                "type": "paragraph",
6586                "content": [
6587                    {"type": "text", "text": "Run "},
6588                    {
6589                        "type": "text",
6590                        "text": "ADD `custom_threshold` TEXT NOT NULL",
6591                        "marks": [{"type": "code"}]
6592                    },
6593                    {"type": "text", "text": " to update the schema."}
6594                ]
6595            }]
6596        }"#;
6597        let original: AdfDocument = serde_json::from_str(json).unwrap();
6598        let md = adf_to_markdown(&original).unwrap();
6599        let roundtripped = markdown_to_adf(&md).unwrap();
6600        let para = &roundtripped.content[0];
6601        let children = para.content.as_ref().unwrap();
6602        assert_eq!(children.len(), 3, "expected 3 children, got: {children:?}");
6603        assert_eq!(children[0].text.as_deref(), Some("Run "));
6604        assert_eq!(
6605            children[1].text.as_deref(),
6606            Some("ADD `custom_threshold` TEXT NOT NULL")
6607        );
6608        let marks = children[1].marks.as_ref().unwrap();
6609        assert_eq!(marks.len(), 1);
6610        assert_eq!(marks[0].mark_type, "code");
6611        assert_eq!(children[2].text.as_deref(), Some(" to update the schema."));
6612    }
6613
6614    /// A code-marked text containing a run of two backticks should be
6615    /// emitted with triple-backtick delimiters and round-trip intact —
6616    /// the first line of the paragraph also starts with the fence so this
6617    /// exercises the info-string-with-backtick fence-opener rejection.
6618    #[test]
6619    fn inline_code_with_double_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                "x `` y",
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.len(), 1);
6632        assert_eq!(content[0].text.as_deref(), Some("x `` y"));
6633        let marks = content[0].marks.as_ref().unwrap();
6634        assert_eq!(marks[0].mark_type, "code");
6635    }
6636
6637    /// A code-marked text that begins with a backtick must be padded on
6638    /// both sides so the CommonMark space-stripping rule reconstructs it.
6639    #[test]
6640    fn inline_code_leading_backtick_roundtrip() {
6641        let doc = AdfDocument {
6642            version: 1,
6643            doc_type: "doc".to_string(),
6644            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6645                "`start",
6646                vec![AdfMark::code()],
6647            )])],
6648        };
6649        let md = adf_to_markdown(&doc).unwrap();
6650        let roundtripped = markdown_to_adf(&md).unwrap();
6651        let content = roundtripped.content[0].content.as_ref().unwrap();
6652        assert_eq!(content[0].text.as_deref(), Some("`start"));
6653        assert_eq!(content[0].marks.as_ref().unwrap()[0].mark_type, "code");
6654    }
6655
6656    /// A code-marked text that ends with a backtick must also survive.
6657    #[test]
6658    fn inline_code_trailing_backtick_roundtrip() {
6659        let doc = AdfDocument {
6660            version: 1,
6661            doc_type: "doc".to_string(),
6662            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6663                "end`",
6664                vec![AdfMark::code()],
6665            )])],
6666        };
6667        let md = adf_to_markdown(&doc).unwrap();
6668        let roundtripped = markdown_to_adf(&md).unwrap();
6669        let content = roundtripped.content[0].content.as_ref().unwrap();
6670        assert_eq!(content[0].text.as_deref(), Some("end`"));
6671    }
6672
6673    /// Content that both begins and ends with a space (but is not all
6674    /// spaces) needs padding so the stripping rule leaves it intact.
6675    #[test]
6676    fn inline_code_space_padded_content_roundtrip() {
6677        let doc = AdfDocument {
6678            version: 1,
6679            doc_type: "doc".to_string(),
6680            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6681                " foo ",
6682                vec![AdfMark::code()],
6683            )])],
6684        };
6685        let md = adf_to_markdown(&doc).unwrap();
6686        let roundtripped = markdown_to_adf(&md).unwrap();
6687        let content = roundtripped.content[0].content.as_ref().unwrap();
6688        assert_eq!(content[0].text.as_deref(), Some(" foo "));
6689    }
6690
6691    /// All-space content must round-trip without the stripping rule
6692    /// kicking in (per CommonMark: all-space content is not stripped).
6693    #[test]
6694    fn inline_code_all_spaces_roundtrip() {
6695        let doc = AdfDocument {
6696            version: 1,
6697            doc_type: "doc".to_string(),
6698            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6699                "   ",
6700                vec![AdfMark::code()],
6701            )])],
6702        };
6703        let md = adf_to_markdown(&doc).unwrap();
6704        let roundtripped = markdown_to_adf(&md).unwrap();
6705        let content = roundtripped.content[0].content.as_ref().unwrap();
6706        assert_eq!(content[0].text.as_deref(), Some("   "));
6707    }
6708
6709    /// A code+link mark where the code text contains a backtick must also
6710    /// round-trip — verifies the link branch of code-span rendering.
6711    #[test]
6712    fn inline_code_with_link_and_backtick_roundtrip() {
6713        let doc = AdfDocument {
6714            version: 1,
6715            doc_type: "doc".to_string(),
6716            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6717                "fn `inner`",
6718                vec![AdfMark::code(), AdfMark::link("https://example.com")],
6719            )])],
6720        };
6721        let md = adf_to_markdown(&doc).unwrap();
6722        assert!(
6723            md.contains("`` fn `inner` ``"),
6724            "expected padded double-backtick delimiters inside link, got: {md}"
6725        );
6726        let roundtripped = markdown_to_adf(&md).unwrap();
6727        let content = roundtripped.content[0].content.as_ref().unwrap();
6728        assert_eq!(content[0].text.as_deref(), Some("fn `inner`"));
6729        let mark_types: Vec<&str> = content[0]
6730            .marks
6731            .as_ref()
6732            .unwrap()
6733            .iter()
6734            .map(|m| m.mark_type.as_str())
6735            .collect();
6736        assert!(mark_types.contains(&"code"));
6737        assert!(mark_types.contains(&"link"));
6738    }
6739
6740    /// Unmatched opening backticks must not be parsed as a code span.
6741    #[test]
6742    fn inline_code_unmatched_run_is_plain_text() {
6743        let doc = markdown_to_adf("foo ``bar baz").unwrap();
6744        let content = doc.content[0].content.as_ref().unwrap();
6745        assert_eq!(content.len(), 1);
6746        assert_eq!(content[0].text.as_deref(), Some("foo ``bar baz"));
6747        assert!(content[0].marks.is_none());
6748    }
6749
6750    /// Mismatched delimiter lengths must not form a code span.  Per
6751    /// CommonMark the opening 2-backtick run and the trailing 1-backtick
6752    /// run never form a valid code span and the characters stay literal.
6753    #[test]
6754    fn inline_code_mismatched_delimiters_is_plain_text() {
6755        let doc = markdown_to_adf("``foo` bar").unwrap();
6756        let content = doc.content[0].content.as_ref().unwrap();
6757        assert_eq!(content.len(), 1);
6758        assert_eq!(content[0].text.as_deref(), Some("``foo` bar"));
6759        assert!(content[0].marks.is_none());
6760    }
6761
6762    #[test]
6763    fn inline_code_delimiter_chooses_correct_length() {
6764        assert_eq!(inline_code_delimiter("no ticks"), (1, false));
6765        assert_eq!(inline_code_delimiter("one ` here"), (2, false));
6766        assert_eq!(inline_code_delimiter("two `` here"), (3, false));
6767        assert_eq!(inline_code_delimiter("three ``` here"), (4, false));
6768        assert_eq!(inline_code_delimiter("`leading"), (2, true));
6769        assert_eq!(inline_code_delimiter("trailing`"), (2, true));
6770        assert_eq!(inline_code_delimiter(" foo "), (1, true));
6771        assert_eq!(inline_code_delimiter(" "), (1, false));
6772        assert_eq!(inline_code_delimiter("   "), (1, false));
6773        assert_eq!(inline_code_delimiter(" foo"), (1, false));
6774    }
6775
6776    #[test]
6777    fn try_parse_inline_code_strips_paired_spaces() {
6778        let (end, content) = try_parse_inline_code("`` `foo` ``", 0).unwrap();
6779        assert_eq!(end, 11);
6780        assert_eq!(content, "`foo`");
6781    }
6782
6783    #[test]
6784    fn try_parse_inline_code_all_space_content_is_preserved() {
6785        let (_end, content) = try_parse_inline_code("`   `", 0).unwrap();
6786        assert_eq!(content, "   ");
6787    }
6788
6789    #[test]
6790    fn try_parse_inline_code_single_run_matches_first_close() {
6791        let (end, content) = try_parse_inline_code("`foo` tail", 0).unwrap();
6792        assert_eq!(end, 5);
6793        assert_eq!(content, "foo");
6794    }
6795
6796    #[test]
6797    fn try_parse_inline_code_no_match_returns_none() {
6798        assert!(try_parse_inline_code("``unmatched", 0).is_none());
6799        assert!(try_parse_inline_code("plain text", 0).is_none());
6800    }
6801
6802    #[test]
6803    fn is_code_fence_opener_rejects_info_with_backtick() {
6804        assert!(is_code_fence_opener("```"));
6805        assert!(is_code_fence_opener("```rust"));
6806        assert!(is_code_fence_opener("```\"\""));
6807        assert!(!is_code_fence_opener("```x `` y```"));
6808        assert!(!is_code_fence_opener("``not-enough"));
6809        assert!(!is_code_fence_opener("no fence"));
6810    }
6811
6812    #[test]
6813    fn inline_strikethrough() {
6814        let doc = markdown_to_adf("Some ~~deleted~~ text").unwrap();
6815        let content = doc.content[0].content.as_ref().unwrap();
6816        let strike_node = &content[1];
6817        assert_eq!(strike_node.text.as_deref(), Some("deleted"));
6818        let marks = strike_node.marks.as_ref().unwrap();
6819        assert_eq!(marks[0].mark_type, "strike");
6820    }
6821
6822    #[test]
6823    fn inline_link() {
6824        let doc = markdown_to_adf("Click [here](https://example.com) now").unwrap();
6825        let content = doc.content[0].content.as_ref().unwrap();
6826        let link_node = &content[1];
6827        assert_eq!(link_node.text.as_deref(), Some("here"));
6828        let marks = link_node.marks.as_ref().unwrap();
6829        assert_eq!(marks[0].mark_type, "link");
6830    }
6831
6832    #[test]
6833    fn block_image() {
6834        let md = "![Alt text](https://example.com/image.png)";
6835        let doc = markdown_to_adf(md).unwrap();
6836        assert_eq!(doc.content[0].node_type, "mediaSingle");
6837    }
6838
6839    #[test]
6840    fn table() {
6841        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |";
6842        let doc = markdown_to_adf(md).unwrap();
6843        assert_eq!(doc.content[0].node_type, "table");
6844        let rows = doc.content[0].content.as_ref().unwrap();
6845        assert_eq!(rows.len(), 2); // header + 1 body row
6846    }
6847
6848    // ── adf_to_markdown tests ───────────────────────────────────────
6849
6850    #[test]
6851    fn adf_paragraph_to_markdown() {
6852        let doc = AdfDocument {
6853            version: 1,
6854            doc_type: "doc".to_string(),
6855            content: vec![AdfNode::paragraph(vec![AdfNode::text("Hello world")])],
6856        };
6857        let md = adf_to_markdown(&doc).unwrap();
6858        assert_eq!(md.trim(), "Hello world");
6859    }
6860
6861    #[test]
6862    fn adf_heading_to_markdown() {
6863        let doc = AdfDocument {
6864            version: 1,
6865            doc_type: "doc".to_string(),
6866            content: vec![AdfNode::heading(2, vec![AdfNode::text("Title")])],
6867        };
6868        let md = adf_to_markdown(&doc).unwrap();
6869        assert_eq!(md.trim(), "## Title");
6870    }
6871
6872    #[test]
6873    fn adf_bold_to_markdown() {
6874        let doc = AdfDocument {
6875            version: 1,
6876            doc_type: "doc".to_string(),
6877            content: vec![AdfNode::paragraph(vec![
6878                AdfNode::text("Normal "),
6879                AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
6880                AdfNode::text(" text"),
6881            ])],
6882        };
6883        let md = adf_to_markdown(&doc).unwrap();
6884        assert_eq!(md.trim(), "Normal **bold** text");
6885    }
6886
6887    #[test]
6888    fn adf_code_block_to_markdown() {
6889        let doc = AdfDocument {
6890            version: 1,
6891            doc_type: "doc".to_string(),
6892            content: vec![AdfNode::code_block(Some("rust"), "let x = 1;")],
6893        };
6894        let md = adf_to_markdown(&doc).unwrap();
6895        assert!(md.contains("```rust"));
6896        assert!(md.contains("let x = 1;"));
6897        assert!(md.contains("```"));
6898    }
6899
6900    #[test]
6901    fn adf_rule_to_markdown() {
6902        let doc = AdfDocument {
6903            version: 1,
6904            doc_type: "doc".to_string(),
6905            content: vec![AdfNode::rule()],
6906        };
6907        let md = adf_to_markdown(&doc).unwrap();
6908        assert!(md.contains("---"));
6909    }
6910
6911    #[test]
6912    fn adf_bullet_list_to_markdown() {
6913        let doc = AdfDocument {
6914            version: 1,
6915            doc_type: "doc".to_string(),
6916            content: vec![AdfNode::bullet_list(vec![
6917                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("A")])]),
6918                AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("B")])]),
6919            ])],
6920        };
6921        let md = adf_to_markdown(&doc).unwrap();
6922        assert!(md.contains("- A"));
6923        assert!(md.contains("- B"));
6924    }
6925
6926    #[test]
6927    fn adf_link_to_markdown() {
6928        let doc = AdfDocument {
6929            version: 1,
6930            doc_type: "doc".to_string(),
6931            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
6932                "click",
6933                vec![AdfMark::link("https://example.com")],
6934            )])],
6935        };
6936        let md = adf_to_markdown(&doc).unwrap();
6937        assert_eq!(md.trim(), "[click](https://example.com)");
6938    }
6939
6940    #[test]
6941    fn unsupported_block_preserved_as_json() {
6942        let doc = AdfDocument {
6943            version: 1,
6944            doc_type: "doc".to_string(),
6945            content: vec![AdfNode {
6946                node_type: "unknownBlock".to_string(),
6947                attrs: Some(serde_json::json!({"key": "value"})),
6948                content: None,
6949                text: None,
6950                marks: None,
6951                local_id: None,
6952                parameters: None,
6953            }],
6954        };
6955        let md = adf_to_markdown(&doc).unwrap();
6956        assert!(md.contains("```adf-unsupported"));
6957        assert!(md.contains("\"unknownBlock\""));
6958    }
6959
6960    #[test]
6961    fn unsupported_block_round_trips() {
6962        let original = AdfDocument {
6963            version: 1,
6964            doc_type: "doc".to_string(),
6965            content: vec![AdfNode {
6966                node_type: "unknownBlock".to_string(),
6967                attrs: Some(serde_json::json!({"key": "value"})),
6968                content: None,
6969                text: None,
6970                marks: None,
6971                local_id: None,
6972                parameters: None,
6973            }],
6974        };
6975        let md = adf_to_markdown(&original).unwrap();
6976        let restored = markdown_to_adf(&md).unwrap();
6977        assert_eq!(restored.content[0].node_type, "unknownBlock");
6978        assert_eq!(restored.content[0].attrs.as_ref().unwrap()["key"], "value");
6979    }
6980
6981    // ── Round-trip tests ────────────────────────────────────────────
6982
6983    #[test]
6984    fn round_trip_simple_document() {
6985        let md = "# Hello\n\nSome text with **bold** and *italic*.\n\n- Item 1\n- Item 2\n";
6986        let adf = markdown_to_adf(md).unwrap();
6987        let restored = adf_to_markdown(&adf).unwrap();
6988
6989        assert!(restored.contains("# Hello"));
6990        assert!(restored.contains("**bold**"));
6991        assert!(restored.contains("*italic*"));
6992        assert!(restored.contains("- Item 1"));
6993        assert!(restored.contains("- Item 2"));
6994    }
6995
6996    #[test]
6997    fn round_trip_code_block() {
6998        let md = "```python\nprint('hello')\n```\n";
6999        let adf = markdown_to_adf(md).unwrap();
7000        let restored = adf_to_markdown(&adf).unwrap();
7001
7002        assert!(restored.contains("```python"));
7003        assert!(restored.contains("print('hello')"));
7004    }
7005
7006    #[test]
7007    fn round_trip_code_block_no_attrs() {
7008        let adf_json = r#"{"version":1,"type":"doc","content":[
7009            {"type":"codeBlock","content":[{"type":"text","text":"plain code"}]}
7010        ]}"#;
7011        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7012        assert!(doc.content[0].attrs.is_none());
7013        let md = adf_to_markdown(&doc).unwrap();
7014        let round_tripped = markdown_to_adf(&md).unwrap();
7015        assert!(round_tripped.content[0].attrs.is_none());
7016    }
7017
7018    #[test]
7019    fn round_trip_code_block_empty_language() {
7020        let adf_json = r#"{"version":1,"type":"doc","content":[
7021            {"type":"codeBlock","attrs":{"language":""},"content":[{"type":"text","text":"simple code block no backtick"}]}
7022        ]}"#;
7023        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7024        let attrs = doc.content[0].attrs.as_ref().unwrap();
7025        assert_eq!(attrs["language"], "");
7026        let md = adf_to_markdown(&doc).unwrap();
7027        let round_tripped = markdown_to_adf(&md).unwrap();
7028        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7029        assert_eq!(rt_attrs["language"], "");
7030    }
7031
7032    #[test]
7033    fn round_trip_code_block_with_language() {
7034        let adf_json = r#"{"version":1,"type":"doc","content":[
7035            {"type":"codeBlock","attrs":{"language":"python"},"content":[{"type":"text","text":"print('hi')"}]}
7036        ]}"#;
7037        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
7038        let md = adf_to_markdown(&doc).unwrap();
7039        let round_tripped = markdown_to_adf(&md).unwrap();
7040        let rt_attrs = round_tripped.content[0].attrs.as_ref().unwrap();
7041        assert_eq!(rt_attrs["language"], "python");
7042    }
7043
7044    #[test]
7045    fn multiple_paragraphs() {
7046        let md = "First paragraph.\n\nSecond paragraph.\n";
7047        let adf = markdown_to_adf(md).unwrap();
7048        assert_eq!(adf.content.len(), 2);
7049        assert_eq!(adf.content[0].node_type, "paragraph");
7050        assert_eq!(adf.content[1].node_type, "paragraph");
7051    }
7052
7053    // ── Additional markdown_to_adf tests ───────────────────────────────
7054
7055    #[test]
7056    fn horizontal_rule_underscores() {
7057        let doc = markdown_to_adf("___").unwrap();
7058        assert_eq!(doc.content[0].node_type, "rule");
7059    }
7060
7061    #[test]
7062    fn not_a_horizontal_rule_too_short() {
7063        let doc = markdown_to_adf("--").unwrap();
7064        assert_eq!(doc.content[0].node_type, "paragraph");
7065    }
7066
7067    #[test]
7068    fn bullet_list_star_marker() {
7069        let md = "* Apple\n* Banana";
7070        let doc = markdown_to_adf(md).unwrap();
7071        assert_eq!(doc.content[0].node_type, "bulletList");
7072        let items = doc.content[0].content.as_ref().unwrap();
7073        assert_eq!(items.len(), 2);
7074    }
7075
7076    #[test]
7077    fn bullet_list_plus_marker() {
7078        let md = "+ One\n+ Two";
7079        let doc = markdown_to_adf(md).unwrap();
7080        assert_eq!(doc.content[0].node_type, "bulletList");
7081    }
7082
7083    #[test]
7084    fn ordered_list_non_one_start() {
7085        let md = "5. Fifth\n6. Sixth";
7086        let doc = markdown_to_adf(md).unwrap();
7087        let node = &doc.content[0];
7088        assert_eq!(node.node_type, "orderedList");
7089        let attrs = node.attrs.as_ref().unwrap();
7090        assert_eq!(attrs["order"], 5);
7091    }
7092
7093    #[test]
7094    fn ordered_list_start_at_one_omits_order_attr() {
7095        // Issue #547: order=1 is the default and must be omitted from attrs
7096        // so that ADF→JFM→ADF round-trip is byte-identical for the common
7097        // case where the source ADF has no attrs object on orderedList.
7098        let md = "1. First\n2. Second";
7099        let doc = markdown_to_adf(md).unwrap();
7100        let node = &doc.content[0];
7101        assert_eq!(node.node_type, "orderedList");
7102        assert!(
7103            node.attrs.is_none(),
7104            "attrs should be omitted when order=1, got: {:?}",
7105            node.attrs
7106        );
7107    }
7108
7109    #[test]
7110    fn blockquote_bare_marker() {
7111        // ">" with no space after
7112        let md = ">quoted text";
7113        let doc = markdown_to_adf(md).unwrap();
7114        assert_eq!(doc.content[0].node_type, "blockquote");
7115    }
7116
7117    #[test]
7118    fn image_no_alt() {
7119        let md = "![](https://example.com/img.png)";
7120        let doc = markdown_to_adf(md).unwrap();
7121        let node = &doc.content[0];
7122        assert_eq!(node.node_type, "mediaSingle");
7123        // media child should have no alt attr
7124        let media = &node.content.as_ref().unwrap()[0];
7125        let attrs = media.attrs.as_ref().unwrap();
7126        assert!(attrs.get("alt").is_none());
7127    }
7128
7129    #[test]
7130    fn image_with_alt() {
7131        let md = "![A photo](https://example.com/photo.jpg)";
7132        let doc = markdown_to_adf(md).unwrap();
7133        let media = &doc.content[0].content.as_ref().unwrap()[0];
7134        let attrs = media.attrs.as_ref().unwrap();
7135        assert_eq!(attrs["alt"], "A photo");
7136    }
7137
7138    #[test]
7139    fn table_multi_body_rows() {
7140        let md = "| H1 | H2 |\n| --- | --- |\n| a | b |\n| c | d |";
7141        let doc = markdown_to_adf(md).unwrap();
7142        let rows = doc.content[0].content.as_ref().unwrap();
7143        assert_eq!(rows.len(), 3); // header + 2 body rows
7144                                   // First row cells are tableHeader
7145        let header_cells = rows[0].content.as_ref().unwrap();
7146        assert_eq!(header_cells[0].node_type, "tableHeader");
7147        // Body row cells are tableCell
7148        let body_cells = rows[1].content.as_ref().unwrap();
7149        assert_eq!(body_cells[0].node_type, "tableCell");
7150    }
7151
7152    #[test]
7153    fn table_no_separator_is_not_table() {
7154        // Pipe characters without a separator row should not parse as table
7155        let md = "| not | a table |";
7156        let doc = markdown_to_adf(md).unwrap();
7157        assert_eq!(doc.content[0].node_type, "paragraph");
7158    }
7159
7160    #[test]
7161    fn inline_underscore_bold() {
7162        let doc = markdown_to_adf("Some __bold__ text").unwrap();
7163        let content = doc.content[0].content.as_ref().unwrap();
7164        let bold_node = &content[1];
7165        assert_eq!(bold_node.text.as_deref(), Some("bold"));
7166        let marks = bold_node.marks.as_ref().unwrap();
7167        assert_eq!(marks[0].mark_type, "strong");
7168    }
7169
7170    #[test]
7171    fn inline_underscore_italic() {
7172        let doc = markdown_to_adf("Some _italic_ text").unwrap();
7173        let content = doc.content[0].content.as_ref().unwrap();
7174        let italic_node = &content[1];
7175        assert_eq!(italic_node.text.as_deref(), Some("italic"));
7176        let marks = italic_node.marks.as_ref().unwrap();
7177        assert_eq!(marks[0].mark_type, "em");
7178    }
7179
7180    #[test]
7181    fn intraword_underscore_not_emphasis() {
7182        // Single intraword underscore pair: do_something_useful
7183        let doc = markdown_to_adf("call do_something_useful now").unwrap();
7184        let content = doc.content[0].content.as_ref().unwrap();
7185        assert_eq!(content.len(), 1, "should be a single text node");
7186        assert_eq!(
7187            content[0].text.as_deref(),
7188            Some("call do_something_useful now")
7189        );
7190        assert!(content[0].marks.is_none());
7191    }
7192
7193    #[test]
7194    fn intraword_underscore_multiple() {
7195        // Multiple intraword underscores: a_b_c_d
7196        let doc = markdown_to_adf("use a_b_c_d here").unwrap();
7197        let content = doc.content[0].content.as_ref().unwrap();
7198        assert_eq!(content.len(), 1);
7199        assert_eq!(content[0].text.as_deref(), Some("use a_b_c_d here"));
7200        assert!(content[0].marks.is_none());
7201    }
7202
7203    #[test]
7204    fn intraword_double_underscore_not_bold() {
7205        // Intraword double underscores: foo__bar__baz
7206        let doc = markdown_to_adf("foo__bar__baz").unwrap();
7207        let content = doc.content[0].content.as_ref().unwrap();
7208        assert_eq!(content.len(), 1);
7209        assert_eq!(content[0].text.as_deref(), Some("foo__bar__baz"));
7210        assert!(content[0].marks.is_none());
7211    }
7212
7213    #[test]
7214    fn intraword_triple_underscore_not_bold_italic() {
7215        // Intraword triple underscores: x___y___z
7216        let doc = markdown_to_adf("x___y___z").unwrap();
7217        let content = doc.content[0].content.as_ref().unwrap();
7218        assert_eq!(content.len(), 1);
7219        assert_eq!(content[0].text.as_deref(), Some("x___y___z"));
7220        assert!(content[0].marks.is_none());
7221    }
7222
7223    #[test]
7224    fn underscore_emphasis_still_works_with_spaces() {
7225        // Normal emphasis with spaces around delimiters should still work
7226        let doc = markdown_to_adf("some _italic_ here").unwrap();
7227        let content = doc.content[0].content.as_ref().unwrap();
7228        assert_eq!(content.len(), 3);
7229        assert_eq!(content[1].text.as_deref(), Some("italic"));
7230        let marks = content[1].marks.as_ref().unwrap();
7231        assert_eq!(marks[0].mark_type, "em");
7232    }
7233
7234    #[test]
7235    fn underscore_bold_still_works_with_spaces() {
7236        // Normal bold with spaces around delimiters should still work
7237        let doc = markdown_to_adf("some __bold__ here").unwrap();
7238        let content = doc.content[0].content.as_ref().unwrap();
7239        assert_eq!(content.len(), 3);
7240        assert_eq!(content[1].text.as_deref(), Some("bold"));
7241        let marks = content[1].marks.as_ref().unwrap();
7242        assert_eq!(marks[0].mark_type, "strong");
7243    }
7244
7245    #[test]
7246    fn intraword_underscore_closing_only() {
7247        // Opening _ is valid (preceded by space) but closing _ is intraword: _foo_bar
7248        let doc = markdown_to_adf("_foo_bar").unwrap();
7249        let content = doc.content[0].content.as_ref().unwrap();
7250        assert_eq!(content.len(), 1);
7251        assert_eq!(content[0].text.as_deref(), Some("_foo_bar"));
7252    }
7253
7254    #[test]
7255    fn intraword_double_underscore_closing_only() {
7256        // Opening __ is valid (at start) but closing __ is intraword: __foo__bar
7257        let doc = markdown_to_adf("__foo__bar").unwrap();
7258        let content = doc.content[0].content.as_ref().unwrap();
7259        assert_eq!(content.len(), 1);
7260        assert_eq!(content[0].text.as_deref(), Some("__foo__bar"));
7261    }
7262
7263    #[test]
7264    fn intraword_triple_underscore_closing_only() {
7265        // Opening ___ is valid (at start) but closing ___ is intraword: ___foo___bar
7266        let doc = markdown_to_adf("___foo___bar").unwrap();
7267        let content = doc.content[0].content.as_ref().unwrap();
7268        assert_eq!(content.len(), 1);
7269        assert_eq!(content[0].text.as_deref(), Some("___foo___bar"));
7270    }
7271
7272    #[test]
7273    fn asterisk_emphasis_unaffected_by_intraword_fix() {
7274        // Asterisks should still work for intraword emphasis (CommonMark allows this)
7275        let doc = markdown_to_adf("foo*bar*baz").unwrap();
7276        let content = doc.content[0].content.as_ref().unwrap();
7277        // Asterisks CAN be intraword emphasis per CommonMark
7278        assert!(content.len() > 1 || content[0].marks.is_some());
7279    }
7280
7281    #[test]
7282    fn intraword_underscore_at_start_of_text() {
7283        // Underscore at start of text is not intraword (no preceding alphanumeric)
7284        let doc = markdown_to_adf("_italic_ word").unwrap();
7285        let content = doc.content[0].content.as_ref().unwrap();
7286        assert_eq!(content[0].text.as_deref(), Some("italic"));
7287        let marks = content[0].marks.as_ref().unwrap();
7288        assert_eq!(marks[0].mark_type, "em");
7289    }
7290
7291    #[test]
7292    fn intraword_underscore_at_end_of_text() {
7293        // Underscore at end of text is not intraword (no following alphanumeric)
7294        let doc = markdown_to_adf("word _italic_").unwrap();
7295        let content = doc.content[0].content.as_ref().unwrap();
7296        let last = content.last().unwrap();
7297        assert_eq!(last.text.as_deref(), Some("italic"));
7298        let marks = last.marks.as_ref().unwrap();
7299        assert_eq!(marks[0].mark_type, "em");
7300    }
7301
7302    #[test]
7303    fn intraword_underscore_opening_only() {
7304        // Opening underscore is intraword but closing is not: a_b c_d
7305        // The first _ is intraword (a_b), so it shouldn't open emphasis
7306        let doc = markdown_to_adf("a_b c_d").unwrap();
7307        let content = doc.content[0].content.as_ref().unwrap();
7308        assert_eq!(content.len(), 1);
7309        assert_eq!(content[0].text.as_deref(), Some("a_b c_d"));
7310    }
7311
7312    #[test]
7313    fn intraword_underscore_roundtrip() {
7314        // The original reproducer from issue #438
7315        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call the do_something_useful function"}]}]}"#;
7316        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7317        let jfm = adf_to_markdown(&adf).unwrap();
7318        let roundtripped = markdown_to_adf(&jfm).unwrap();
7319        let content = roundtripped.content[0].content.as_ref().unwrap();
7320        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7321        assert_eq!(
7322            content[0].text.as_deref(),
7323            Some("call the do_something_useful function")
7324        );
7325        assert!(content[0].marks.is_none());
7326    }
7327
7328    #[test]
7329    fn asterisk_emphasis_roundtrip() {
7330        // The original reproducer from issue #452
7331        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status: *confirmed* and active"}]}]}"#;
7332        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7333        let jfm = adf_to_markdown(&adf).unwrap();
7334        let roundtripped = markdown_to_adf(&jfm).unwrap();
7335        let content = roundtripped.content[0].content.as_ref().unwrap();
7336        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7337        assert_eq!(
7338            content[0].text.as_deref(),
7339            Some("Status: *confirmed* and active")
7340        );
7341        assert!(content[0].marks.is_none());
7342    }
7343
7344    #[test]
7345    fn double_asterisk_roundtrip() {
7346        // **bold** delimiters in plain text should not become strong marks
7347        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use **kwargs in Python"}]}]}"#;
7348        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7349        let jfm = adf_to_markdown(&adf).unwrap();
7350        let roundtripped = markdown_to_adf(&jfm).unwrap();
7351        let content = roundtripped.content[0].content.as_ref().unwrap();
7352        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7353        assert_eq!(content[0].text.as_deref(), Some("Use **kwargs in Python"));
7354        assert!(content[0].marks.is_none());
7355    }
7356
7357    #[test]
7358    fn asterisk_with_em_mark_roundtrip() {
7359        // Text that already has an em mark should preserve both the mark and escaped content
7360        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a*b","marks":[{"type":"em"}]}]}]}"#;
7361        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7362        let jfm = adf_to_markdown(&adf).unwrap();
7363        let roundtripped = markdown_to_adf(&jfm).unwrap();
7364        let content = roundtripped.content[0].content.as_ref().unwrap();
7365        // Find the node with em mark
7366        let em_node = content.iter().find(|n| {
7367            n.marks
7368                .as_ref()
7369                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7370        });
7371        assert!(em_node.is_some(), "should have an em-marked node");
7372        assert_eq!(em_node.unwrap().text.as_deref(), Some("a*b"));
7373    }
7374
7375    #[test]
7376    fn lone_asterisk_roundtrip() {
7377        // A single asterisk that cannot form emphasis should round-trip
7378        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"rating: 5 * stars"}]}]}"#;
7379        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7380        let jfm = adf_to_markdown(&adf).unwrap();
7381        let roundtripped = markdown_to_adf(&jfm).unwrap();
7382        let content = roundtripped.content[0].content.as_ref().unwrap();
7383        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7384        assert_eq!(content[0].text.as_deref(), Some("rating: 5 * stars"));
7385    }
7386
7387    #[test]
7388    fn escape_emphasis_markers_unit() {
7389        assert_eq!(escape_emphasis_markers("hello"), "hello");
7390        assert_eq!(escape_emphasis_markers("*bold*"), r"\*bold\*");
7391        assert_eq!(escape_emphasis_markers("**strong**"), r"\*\*strong\*\*");
7392        assert_eq!(escape_emphasis_markers("no stars"), "no stars");
7393        assert_eq!(escape_emphasis_markers("a * b"), r"a \* b");
7394        assert_eq!(escape_emphasis_markers(""), "");
7395    }
7396
7397    #[test]
7398    fn escape_emphasis_markers_underscore_intraword() {
7399        // Intraword underscores (alnum on both sides within the node) are
7400        // left as-is — the JFM parser will reject them as emphasis.
7401        assert_eq!(escape_emphasis_markers("foo_bar"), "foo_bar");
7402        assert_eq!(escape_emphasis_markers("a_b_c"), "a_b_c");
7403        assert_eq!(escape_emphasis_markers("foo__bar"), "foo__bar");
7404        assert_eq!(
7405            escape_emphasis_markers("call do_something_useful"),
7406            "call do_something_useful"
7407        );
7408    }
7409
7410    #[test]
7411    fn escape_emphasis_markers_underscore_at_boundary() {
7412        // Leading and trailing underscores get escaped — adjacent text nodes
7413        // could supply the alphanumeric needed to close emphasis (issue #554).
7414        assert_eq!(escape_emphasis_markers("_Action"), r"\_Action");
7415        assert_eq!(escape_emphasis_markers("Action_"), r"Action\_");
7416        assert_eq!(escape_emphasis_markers("_ "), r"\_ ");
7417        assert_eq!(escape_emphasis_markers(" _"), r" \_");
7418        assert_eq!(escape_emphasis_markers("_"), r"\_");
7419    }
7420
7421    #[test]
7422    fn escape_emphasis_markers_underscore_with_punctuation() {
7423        // Underscores adjacent to punctuation (not alphanumeric) get escaped.
7424        assert_eq!(escape_emphasis_markers("foo _bar"), r"foo \_bar");
7425        assert_eq!(escape_emphasis_markers("foo_ bar"), r"foo\_ bar");
7426        assert_eq!(escape_emphasis_markers("(_x_)"), r"(\_x\_)");
7427    }
7428
7429    #[test]
7430    fn find_unescaped_skips_backslash_escaped() {
7431        // Escaped `**` should not be found
7432        assert_eq!(find_unescaped(r"a\*\*b**", "**"), Some(6));
7433        // No unescaped match at all
7434        assert_eq!(find_unescaped(r"a\*\*b", "**"), None);
7435        // Plain match without any escaping
7436        assert_eq!(find_unescaped("a**b", "**"), Some(1));
7437        // Empty haystack
7438        assert_eq!(find_unescaped("", "**"), None);
7439    }
7440
7441    #[test]
7442    fn find_unescaped_char_skips_backslash_escaped() {
7443        // Escaped `*` should not be found
7444        assert_eq!(find_unescaped_char(r"a\*b*", b'*'), Some(4));
7445        // No unescaped match at all
7446        assert_eq!(find_unescaped_char(r"\*", b'*'), None);
7447        // Plain match
7448        assert_eq!(find_unescaped_char("a*b", b'*'), Some(1));
7449        // Empty haystack
7450        assert_eq!(find_unescaped_char("", b'*'), None);
7451    }
7452
7453    #[test]
7454    fn double_asterisk_in_strong_mark_roundtrip() {
7455        // Text with ** inside a strong mark should preserve the literal **
7456        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"call **kwargs","marks":[{"type":"strong"}]}]}]}"#;
7457        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7458        let jfm = adf_to_markdown(&adf).unwrap();
7459        let roundtripped = markdown_to_adf(&jfm).unwrap();
7460        let content = roundtripped.content[0].content.as_ref().unwrap();
7461        let strong_node = content.iter().find(|n| {
7462            n.marks
7463                .as_ref()
7464                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7465        });
7466        assert!(strong_node.is_some(), "should have a strong-marked node");
7467        assert_eq!(strong_node.unwrap().text.as_deref(), Some("call **kwargs"));
7468    }
7469
7470    #[test]
7471    fn backtick_code_roundtrip() {
7472        // The original reproducer from issue #453
7473        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Set `max_retries` to 3 in the config"}]}]}"#;
7474        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7475        let jfm = adf_to_markdown(&adf).unwrap();
7476        let roundtripped = markdown_to_adf(&jfm).unwrap();
7477        let content = roundtripped.content[0].content.as_ref().unwrap();
7478        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7479        assert_eq!(
7480            content[0].text.as_deref(),
7481            Some("Set `max_retries` to 3 in the config")
7482        );
7483        assert!(content[0].marks.is_none());
7484    }
7485
7486    #[test]
7487    fn multiple_backtick_spans_roundtrip() {
7488        // Multiple backtick-delimited spans in a single text node
7489        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use `foo` and `bar` together"}]}]}"#;
7490        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7491        let jfm = adf_to_markdown(&adf).unwrap();
7492        let roundtripped = markdown_to_adf(&jfm).unwrap();
7493        let content = roundtripped.content[0].content.as_ref().unwrap();
7494        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7495        assert_eq!(
7496            content[0].text.as_deref(),
7497            Some("Use `foo` and `bar` together")
7498        );
7499        assert!(content[0].marks.is_none());
7500    }
7501
7502    #[test]
7503    fn lone_backtick_roundtrip() {
7504        // A single backtick that cannot form a code span should round-trip
7505        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Use a ` character"}]}]}"#;
7506        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7507        let jfm = adf_to_markdown(&adf).unwrap();
7508        let roundtripped = markdown_to_adf(&jfm).unwrap();
7509        let content = roundtripped.content[0].content.as_ref().unwrap();
7510        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7511        assert_eq!(content[0].text.as_deref(), Some("Use a ` character"));
7512        assert!(content[0].marks.is_none());
7513    }
7514
7515    #[test]
7516    fn backtick_with_code_mark_roundtrip() {
7517        // Text that already has a code mark should preserve both the mark and content
7518        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"max_retries","marks":[{"type":"code"}]}]}]}"#;
7519        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7520        let jfm = adf_to_markdown(&adf).unwrap();
7521        assert_eq!(jfm.trim(), "`max_retries`");
7522        let roundtripped = markdown_to_adf(&jfm).unwrap();
7523        let content = roundtripped.content[0].content.as_ref().unwrap();
7524        let code_node = content.iter().find(|n| {
7525            n.marks
7526                .as_ref()
7527                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7528        });
7529        assert!(code_node.is_some(), "should have a code-marked node");
7530        assert_eq!(code_node.unwrap().text.as_deref(), Some("max_retries"));
7531    }
7532
7533    #[test]
7534    fn backtick_with_em_mark_roundtrip() {
7535        // Backtick inside em-marked text should preserve both
7536        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"use `cfg`","marks":[{"type":"em"}]}]}]}"#;
7537        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7538        let jfm = adf_to_markdown(&adf).unwrap();
7539        let roundtripped = markdown_to_adf(&jfm).unwrap();
7540        let content = roundtripped.content[0].content.as_ref().unwrap();
7541        let em_node = content.iter().find(|n| {
7542            n.marks
7543                .as_ref()
7544                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "em"))
7545        });
7546        assert!(em_node.is_some(), "should have an em-marked node");
7547        assert_eq!(em_node.unwrap().text.as_deref(), Some("use `cfg`"));
7548    }
7549
7550    #[test]
7551    fn escape_pipes_in_cell_unit() {
7552        assert_eq!(escape_pipes_in_cell("hello"), "hello");
7553        assert_eq!(escape_pipes_in_cell("a|b"), r"a\|b");
7554        assert_eq!(escape_pipes_in_cell("|"), r"\|");
7555        assert_eq!(escape_pipes_in_cell("|a|b|"), r"\|a\|b\|");
7556        assert_eq!(escape_pipes_in_cell(""), "");
7557        assert_eq!(
7558            escape_pipes_in_cell("`parser.decode[T|json]`"),
7559            r"`parser.decode[T\|json]`"
7560        );
7561    }
7562
7563    #[test]
7564    fn escape_backticks_unit() {
7565        assert_eq!(escape_backticks("hello"), "hello");
7566        assert_eq!(escape_backticks("`code`"), r"\`code\`");
7567        assert_eq!(escape_backticks("no ticks"), "no ticks");
7568        assert_eq!(escape_backticks("a ` b"), r"a \` b");
7569        assert_eq!(escape_backticks(""), "");
7570        assert_eq!(escape_backticks("`a` and `b`"), r"\`a\` and \`b\`");
7571    }
7572
7573    // ── Issue #477: backslash escaping ──────────────────────────────
7574
7575    #[test]
7576    fn backslash_in_text_roundtrip() {
7577        // The original reproducer from issue #477
7578        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"The path is C:\\Users\\admin\\file.txt"}]}]}"#;
7579        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7580        let jfm = adf_to_markdown(&adf).unwrap();
7581        let roundtripped = markdown_to_adf(&jfm).unwrap();
7582        let content = roundtripped.content[0].content.as_ref().unwrap();
7583        assert_eq!(content.len(), 1, "should round-trip as a single text node");
7584        assert_eq!(
7585            content[0].text.as_deref(),
7586            Some(r"The path is C:\Users\admin\file.txt")
7587        );
7588    }
7589
7590    #[test]
7591    fn backslash_emitted_as_double_backslash() {
7592        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\b"}]}]}"#;
7593        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7594        let jfm = adf_to_markdown(&adf).unwrap();
7595        assert!(
7596            jfm.contains(r"a\\b"),
7597            "JFM should contain escaped backslash: {jfm}"
7598        );
7599    }
7600
7601    #[test]
7602    fn consecutive_backslashes_roundtrip() {
7603        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a\\\\b"}]}]}"#;
7604        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7605        let jfm = adf_to_markdown(&adf).unwrap();
7606        let roundtripped = markdown_to_adf(&jfm).unwrap();
7607        let content = roundtripped.content[0].content.as_ref().unwrap();
7608        assert_eq!(
7609            content[0].text.as_deref(),
7610            Some(r"a\\b"),
7611            "consecutive backslashes should survive round-trip"
7612        );
7613    }
7614
7615    #[test]
7616    fn backslash_with_strong_mark_roundtrip() {
7617        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"strong"}]}]}]}"#;
7618        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7619        let jfm = adf_to_markdown(&adf).unwrap();
7620        let roundtripped = markdown_to_adf(&jfm).unwrap();
7621        let content = roundtripped.content[0].content.as_ref().unwrap();
7622        let strong_node = content.iter().find(|n| {
7623            n.marks
7624                .as_ref()
7625                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
7626        });
7627        assert!(strong_node.is_some(), "should have a strong-marked node");
7628        assert_eq!(strong_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7629    }
7630
7631    #[test]
7632    fn backslash_with_code_mark_not_escaped() {
7633        // Code marks emit content verbatim — backslashes should NOT be escaped
7634        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\Users","marks":[{"type":"code"}]}]}]}"#;
7635        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7636        let jfm = adf_to_markdown(&adf).unwrap();
7637        assert_eq!(jfm.trim(), r"`C:\Users`");
7638        let roundtripped = markdown_to_adf(&jfm).unwrap();
7639        let content = roundtripped.content[0].content.as_ref().unwrap();
7640        let code_node = content.iter().find(|n| {
7641            n.marks
7642                .as_ref()
7643                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code"))
7644        });
7645        assert!(code_node.is_some(), "should have a code-marked node");
7646        assert_eq!(code_node.unwrap().text.as_deref(), Some(r"C:\Users"));
7647    }
7648
7649    #[test]
7650    fn backslash_before_special_chars_roundtrip() {
7651        // Backslash before characters that are themselves escaped (* ` :)
7652        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"\\*not bold\\*"}]}]}"#;
7653        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7654        let jfm = adf_to_markdown(&adf).unwrap();
7655        let roundtripped = markdown_to_adf(&jfm).unwrap();
7656        let content = roundtripped.content[0].content.as_ref().unwrap();
7657        assert_eq!(
7658            content[0].text.as_deref(),
7659            Some(r"\*not bold\*"),
7660            "backslash before special char should survive round-trip"
7661        );
7662    }
7663
7664    #[test]
7665    fn backslash_and_newline_in_text_roundtrip() {
7666        // Text with both backslashes and embedded newlines
7667        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"C:\\path\nline2"}]}]}"#;
7668        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7669        let jfm = adf_to_markdown(&adf).unwrap();
7670        let roundtripped = markdown_to_adf(&jfm).unwrap();
7671        let content = roundtripped.content[0].content.as_ref().unwrap();
7672        assert_eq!(
7673            content[0].text.as_deref(),
7674            Some("C:\\path\nline2"),
7675            "backslash and newline should both survive round-trip"
7676        );
7677    }
7678
7679    #[test]
7680    fn lone_backslash_roundtrip() {
7681        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"a \\ b"}]}]}"#;
7682        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7683        let jfm = adf_to_markdown(&adf).unwrap();
7684        let roundtripped = markdown_to_adf(&jfm).unwrap();
7685        let content = roundtripped.content[0].content.as_ref().unwrap();
7686        assert_eq!(content[0].text.as_deref(), Some(r"a \ b"));
7687    }
7688
7689    #[test]
7690    fn trailing_backslash_in_text_roundtrip() {
7691        // A trailing backslash in text content (not a hardBreak) should round-trip
7692        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\"}]}]}"#;
7693        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
7694        let jfm = adf_to_markdown(&adf).unwrap();
7695        let roundtripped = markdown_to_adf(&jfm).unwrap();
7696        let content = roundtripped.content[0].content.as_ref().unwrap();
7697        assert_eq!(
7698            content[0].text.as_deref(),
7699            Some(r"end\"),
7700            "trailing backslash should survive round-trip"
7701        );
7702    }
7703
7704    #[test]
7705    fn escape_bare_urls_unit() {
7706        assert_eq!(escape_bare_urls("hello"), "hello");
7707        assert_eq!(escape_bare_urls(""), "");
7708        assert_eq!(
7709            escape_bare_urls("https://example.com"),
7710            r"\https://example.com"
7711        );
7712        assert_eq!(
7713            escape_bare_urls("http://example.com"),
7714            r"\http://example.com"
7715        );
7716        assert_eq!(
7717            escape_bare_urls("see https://a.com and https://b.com"),
7718            r"see \https://a.com and \https://b.com"
7719        );
7720        // "http" without "://" is not a URL scheme — leave untouched
7721        assert_eq!(escape_bare_urls("http header"), "http header");
7722        assert_eq!(escape_bare_urls("https is secure"), "https is secure");
7723    }
7724
7725    #[test]
7726    fn heading_not_valid_without_space() {
7727        // "#Title" without space should be a paragraph, not heading
7728        let doc = markdown_to_adf("#Title").unwrap();
7729        assert_eq!(doc.content[0].node_type, "paragraph");
7730    }
7731
7732    #[test]
7733    fn heading_level_too_high() {
7734        // ####### (7 hashes) is not a valid heading
7735        let doc = markdown_to_adf("####### Not a heading").unwrap();
7736        assert_eq!(doc.content[0].node_type, "paragraph");
7737    }
7738
7739    #[test]
7740    fn empty_document() {
7741        let doc = markdown_to_adf("").unwrap();
7742        assert!(doc.content.is_empty());
7743    }
7744
7745    #[test]
7746    fn only_blank_lines() {
7747        let doc = markdown_to_adf("\n\n\n").unwrap();
7748        assert!(doc.content.is_empty());
7749    }
7750
7751    #[test]
7752    fn code_block_unterminated() {
7753        // Code block without closing fence
7754        let md = "```rust\nfn main() {}";
7755        let doc = markdown_to_adf(md).unwrap();
7756        assert_eq!(doc.content[0].node_type, "codeBlock");
7757    }
7758
7759    #[test]
7760    fn mixed_document() {
7761        let md = "# Title\n\nA paragraph.\n\n- Item\n\n```\ncode\n```\n\n> quote\n\n---\n\n1. numbered\n";
7762        let doc = markdown_to_adf(md).unwrap();
7763        let types: Vec<&str> = doc.content.iter().map(|n| n.node_type.as_str()).collect();
7764        assert_eq!(
7765            types,
7766            vec![
7767                "heading",
7768                "paragraph",
7769                "bulletList",
7770                "codeBlock",
7771                "blockquote",
7772                "rule",
7773                "orderedList",
7774            ]
7775        );
7776    }
7777
7778    // ── Additional adf_to_markdown tests ───────────────────────────────
7779
7780    #[test]
7781    fn adf_ordered_list_to_markdown() {
7782        let doc = AdfDocument {
7783            version: 1,
7784            doc_type: "doc".to_string(),
7785            content: vec![AdfNode::ordered_list(
7786                vec![
7787                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("First")])]),
7788                    AdfNode::list_item(vec![AdfNode::paragraph(vec![AdfNode::text("Second")])]),
7789                ],
7790                None,
7791            )],
7792        };
7793        let md = adf_to_markdown(&doc).unwrap();
7794        assert!(md.contains("1. First"));
7795        assert!(md.contains("2. Second"));
7796    }
7797
7798    #[test]
7799    fn adf_ordered_list_custom_start() {
7800        let doc = AdfDocument {
7801            version: 1,
7802            doc_type: "doc".to_string(),
7803            content: vec![AdfNode::ordered_list(
7804                vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
7805                    AdfNode::text("Third"),
7806                ])])],
7807                Some(3),
7808            )],
7809        };
7810        let md = adf_to_markdown(&doc).unwrap();
7811        assert!(md.contains("3. Third"));
7812    }
7813
7814    #[test]
7815    fn adf_blockquote_to_markdown() {
7816        let doc = AdfDocument {
7817            version: 1,
7818            doc_type: "doc".to_string(),
7819            content: vec![AdfNode::blockquote(vec![AdfNode::paragraph(vec![
7820                AdfNode::text("A quote"),
7821            ])])],
7822        };
7823        let md = adf_to_markdown(&doc).unwrap();
7824        assert!(md.contains("> A quote"));
7825    }
7826
7827    #[test]
7828    fn adf_table_to_markdown() {
7829        let doc = AdfDocument {
7830            version: 1,
7831            doc_type: "doc".to_string(),
7832            content: vec![AdfNode::table(vec![
7833                AdfNode::table_row(vec![
7834                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Name")])]),
7835                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("Value")])]),
7836                ]),
7837                AdfNode::table_row(vec![
7838                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a")])]),
7839                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("1")])]),
7840                ]),
7841            ])],
7842        };
7843        let md = adf_to_markdown(&doc).unwrap();
7844        assert!(md.contains("| Name | Value |"));
7845        assert!(md.contains("| --- | --- |"));
7846        assert!(md.contains("| a | 1 |"));
7847    }
7848
7849    #[test]
7850    fn adf_media_to_markdown() {
7851        let doc = AdfDocument {
7852            version: 1,
7853            doc_type: "doc".to_string(),
7854            content: vec![AdfNode::media_single(
7855                "https://example.com/img.png",
7856                Some("Alt"),
7857            )],
7858        };
7859        let md = adf_to_markdown(&doc).unwrap();
7860        assert!(md.contains("![Alt](https://example.com/img.png)"));
7861    }
7862
7863    #[test]
7864    fn adf_media_no_alt_to_markdown() {
7865        let doc = AdfDocument {
7866            version: 1,
7867            doc_type: "doc".to_string(),
7868            content: vec![AdfNode::media_single("https://example.com/img.png", None)],
7869        };
7870        let md = adf_to_markdown(&doc).unwrap();
7871        assert!(md.contains("![](https://example.com/img.png)"));
7872    }
7873
7874    #[test]
7875    fn adf_italic_to_markdown() {
7876        let doc = AdfDocument {
7877            version: 1,
7878            doc_type: "doc".to_string(),
7879            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7880                "emphasis",
7881                vec![AdfMark::em()],
7882            )])],
7883        };
7884        let md = adf_to_markdown(&doc).unwrap();
7885        assert_eq!(md.trim(), "*emphasis*");
7886    }
7887
7888    #[test]
7889    fn adf_strikethrough_to_markdown() {
7890        let doc = AdfDocument {
7891            version: 1,
7892            doc_type: "doc".to_string(),
7893            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7894                "deleted",
7895                vec![AdfMark::strike()],
7896            )])],
7897        };
7898        let md = adf_to_markdown(&doc).unwrap();
7899        assert_eq!(md.trim(), "~~deleted~~");
7900    }
7901
7902    #[test]
7903    fn adf_inline_code_to_markdown() {
7904        let doc = AdfDocument {
7905            version: 1,
7906            doc_type: "doc".to_string(),
7907            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7908                "code",
7909                vec![AdfMark::code()],
7910            )])],
7911        };
7912        let md = adf_to_markdown(&doc).unwrap();
7913        assert_eq!(md.trim(), "`code`");
7914    }
7915
7916    #[test]
7917    fn adf_code_with_link_to_markdown() {
7918        let doc = AdfDocument {
7919            version: 1,
7920            doc_type: "doc".to_string(),
7921            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7922                "func",
7923                vec![AdfMark::code(), AdfMark::link("https://example.com")],
7924            )])],
7925        };
7926        let md = adf_to_markdown(&doc).unwrap();
7927        assert_eq!(md.trim(), "[`func`](https://example.com)");
7928    }
7929
7930    #[test]
7931    fn adf_bold_italic_to_markdown() {
7932        let doc = AdfDocument {
7933            version: 1,
7934            doc_type: "doc".to_string(),
7935            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7936                "both",
7937                vec![AdfMark::strong(), AdfMark::em()],
7938            )])],
7939        };
7940        let md = adf_to_markdown(&doc).unwrap();
7941        assert_eq!(md.trim(), "***both***");
7942    }
7943
7944    #[test]
7945    fn adf_bold_link_to_markdown() {
7946        let doc = AdfDocument {
7947            version: 1,
7948            doc_type: "doc".to_string(),
7949            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7950                "bold link",
7951                vec![AdfMark::strong(), AdfMark::link("https://example.com")],
7952            )])],
7953        };
7954        let md = adf_to_markdown(&doc).unwrap();
7955        assert_eq!(md.trim(), "**[bold link](https://example.com)**");
7956    }
7957
7958    #[test]
7959    fn adf_strikethrough_bold_to_markdown() {
7960        let doc = AdfDocument {
7961            version: 1,
7962            doc_type: "doc".to_string(),
7963            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
7964                "struck",
7965                vec![AdfMark::strike(), AdfMark::strong()],
7966            )])],
7967        };
7968        let md = adf_to_markdown(&doc).unwrap();
7969        assert_eq!(md.trim(), "~~**struck**~~");
7970    }
7971
7972    #[test]
7973    fn adf_hard_break_to_markdown() {
7974        let doc = AdfDocument {
7975            version: 1,
7976            doc_type: "doc".to_string(),
7977            content: vec![AdfNode::paragraph(vec![
7978                AdfNode::text("Line 1"),
7979                AdfNode::hard_break(),
7980                AdfNode::text("Line 2"),
7981            ])],
7982        };
7983        let md = adf_to_markdown(&doc).unwrap();
7984        assert!(md.contains("Line 1\\\n  Line 2"));
7985    }
7986
7987    #[test]
7988    fn adf_unsupported_inline_to_markdown() {
7989        let doc = AdfDocument {
7990            version: 1,
7991            doc_type: "doc".to_string(),
7992            content: vec![AdfNode::paragraph(vec![AdfNode {
7993                node_type: "unknownInline".to_string(),
7994                attrs: Some(serde_json::json!({"key": "value"})),
7995                content: None,
7996                text: None,
7997                marks: None,
7998                local_id: None,
7999                parameters: None,
8000            }])],
8001        };
8002        let md = adf_to_markdown(&doc).unwrap();
8003        // Unsupported inline nodes now round-trip via an `:adf-unsupported`
8004        // directive carrying the full node JSON (issue #1117), replacing the
8005        // old lossy `<!-- unsupported inline -->` comment.
8006        assert!(md.contains(":adf-unsupported[]{json="));
8007        assert!(md.contains("unknownInline"));
8008        assert!(!md.contains("<!-- unsupported inline"));
8009    }
8010
8011    #[test]
8012    fn unsupported_inline_round_trips() {
8013        let original = AdfDocument {
8014            version: 1,
8015            doc_type: "doc".to_string(),
8016            content: vec![AdfNode::paragraph(vec![
8017                AdfNode::text("before "),
8018                AdfNode {
8019                    node_type: "unknownInline".to_string(),
8020                    attrs: Some(serde_json::json!({"key": "value", "n": 3})),
8021                    content: None,
8022                    text: Some("fallback".to_string()),
8023                    marks: None,
8024                    local_id: None,
8025                    parameters: None,
8026                },
8027                AdfNode::text(" after"),
8028            ])],
8029        };
8030        let md = adf_to_markdown(&original).unwrap();
8031        let restored = markdown_to_adf(&md).unwrap();
8032        // The paragraph's unknown inline node survives with its attrs and text.
8033        let para = &restored.content[0];
8034        let unknown = para
8035            .content
8036            .as_ref()
8037            .unwrap()
8038            .iter()
8039            .find(|n| n.node_type == "unknownInline")
8040            .expect("unknownInline node preserved");
8041        assert_eq!(unknown.attrs.as_ref().unwrap()["key"], "value");
8042        assert_eq!(unknown.attrs.as_ref().unwrap()["n"], 3);
8043        assert_eq!(unknown.text.as_deref(), Some("fallback"));
8044    }
8045
8046    // ── mediaInline tests (issue #476) ─────────────────────────────────
8047
8048    #[test]
8049    fn adf_media_inline_to_markdown() {
8050        let doc = AdfDocument {
8051            version: 1,
8052            doc_type: "doc".to_string(),
8053            content: vec![AdfNode::paragraph(vec![
8054                AdfNode::text("see "),
8055                AdfNode::media_inline(serde_json::json!({
8056                    "type": "image",
8057                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
8058                    "collection": "contentId-111111",
8059                    "width": 200,
8060                    "height": 100
8061                })),
8062                AdfNode::text(" for details"),
8063            ])],
8064        };
8065        let md = adf_to_markdown(&doc).unwrap();
8066        assert!(md.contains(":media-inline[]{"), "got: {md}");
8067        assert!(md.contains("type=image"));
8068        assert!(md.contains("id=abcdef01-2345-6789-abcd-abcdef012345"));
8069        assert!(md.contains("collection=contentId-111111"));
8070        assert!(md.contains("width=200"));
8071        assert!(md.contains("height=100"));
8072        assert!(!md.contains("<!-- unsupported inline"));
8073    }
8074
8075    #[test]
8076    fn media_inline_round_trip() {
8077        let doc = AdfDocument {
8078            version: 1,
8079            doc_type: "doc".to_string(),
8080            content: vec![AdfNode::paragraph(vec![
8081                AdfNode::text("see "),
8082                AdfNode::media_inline(serde_json::json!({
8083                    "type": "image",
8084                    "id": "abcdef01-2345-6789-abcd-abcdef012345",
8085                    "collection": "contentId-111111",
8086                    "width": 200,
8087                    "height": 100
8088                })),
8089                AdfNode::text(" for details"),
8090            ])],
8091        };
8092        let md = adf_to_markdown(&doc).unwrap();
8093        let rt = markdown_to_adf(&md).unwrap();
8094
8095        let content = rt.content[0].content.as_ref().unwrap();
8096        assert_eq!(content[0].text.as_deref(), Some("see "));
8097        assert_eq!(content[1].node_type, "mediaInline");
8098        let attrs = content[1].attrs.as_ref().unwrap();
8099        assert_eq!(attrs["type"], "image");
8100        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8101        assert_eq!(attrs["collection"], "contentId-111111");
8102        assert_eq!(attrs["width"], 200);
8103        assert_eq!(attrs["height"], 100);
8104        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8105    }
8106
8107    #[test]
8108    fn media_inline_external_url_round_trip() {
8109        let doc = AdfDocument {
8110            version: 1,
8111            doc_type: "doc".to_string(),
8112            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8113                serde_json::json!({
8114                    "type": "external",
8115                    "url": "https://example.com/image.png",
8116                    "alt": "example",
8117                    "width": 400,
8118                    "height": 300
8119                }),
8120            )])],
8121        };
8122        let md = adf_to_markdown(&doc).unwrap();
8123        let rt = markdown_to_adf(&md).unwrap();
8124
8125        let content = rt.content[0].content.as_ref().unwrap();
8126        assert_eq!(content[0].node_type, "mediaInline");
8127        let attrs = content[0].attrs.as_ref().unwrap();
8128        assert_eq!(attrs["type"], "external");
8129        assert_eq!(attrs["url"], "https://example.com/image.png");
8130        assert_eq!(attrs["alt"], "example");
8131        assert_eq!(attrs["width"], 400);
8132        assert_eq!(attrs["height"], 300);
8133    }
8134
8135    #[test]
8136    fn media_inline_minimal_attrs() {
8137        let doc = AdfDocument {
8138            version: 1,
8139            doc_type: "doc".to_string(),
8140            content: vec![AdfNode::paragraph(vec![AdfNode::media_inline(
8141                serde_json::json!({"type": "file", "id": "abc-123"}),
8142            )])],
8143        };
8144        let md = adf_to_markdown(&doc).unwrap();
8145        let rt = markdown_to_adf(&md).unwrap();
8146
8147        let content = rt.content[0].content.as_ref().unwrap();
8148        assert_eq!(content[0].node_type, "mediaInline");
8149        let attrs = content[0].attrs.as_ref().unwrap();
8150        assert_eq!(attrs["type"], "file");
8151        assert_eq!(attrs["id"], "abc-123");
8152    }
8153
8154    #[test]
8155    fn media_inline_from_issue_476_reproducer() {
8156        // Exact reproducer from issue #476
8157        let adf_json: serde_json::Value = serde_json::json!({
8158            "type": "doc",
8159            "version": 1,
8160            "content": [
8161                {
8162                    "type": "paragraph",
8163                    "content": [
8164                        {"type": "text", "text": "see "},
8165                        {
8166                            "type": "mediaInline",
8167                            "attrs": {
8168                                "collection": "contentId-111111",
8169                                "height": 100,
8170                                "id": "abcdef01-2345-6789-abcd-abcdef012345",
8171                                "localId": "aabbccdd-1234-5678-abcd-aabbccdd1234",
8172                                "type": "image",
8173                                "width": 200
8174                            }
8175                        },
8176                        {"type": "text", "text": " for details"}
8177                    ]
8178                }
8179            ]
8180        });
8181        let doc: AdfDocument = serde_json::from_value(adf_json).unwrap();
8182        let md = adf_to_markdown(&doc).unwrap();
8183        assert!(
8184            !md.contains("<!-- unsupported inline"),
8185            "mediaInline should not be unsupported; got: {md}"
8186        );
8187
8188        let rt = markdown_to_adf(&md).unwrap();
8189        let content = rt.content[0].content.as_ref().unwrap();
8190        assert_eq!(content[1].node_type, "mediaInline");
8191        let attrs = content[1].attrs.as_ref().unwrap();
8192        assert_eq!(attrs["type"], "image");
8193        assert_eq!(attrs["id"], "abcdef01-2345-6789-abcd-abcdef012345");
8194        assert_eq!(attrs["collection"], "contentId-111111");
8195        assert_eq!(attrs["width"], 200);
8196        assert_eq!(attrs["height"], 100);
8197        assert_eq!(attrs["localId"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
8198    }
8199
8200    #[test]
8201    fn emoji_shortcode() {
8202        let doc = markdown_to_adf("Hello :wave: world").unwrap();
8203        let content = doc.content[0].content.as_ref().unwrap();
8204        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8205        assert_eq!(content[1].node_type, "emoji");
8206        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":wave:");
8207        assert_eq!(content[2].text.as_deref(), Some(" world"));
8208    }
8209
8210    #[test]
8211    fn adf_emoji_to_markdown() {
8212        let doc = AdfDocument {
8213            version: 1,
8214            doc_type: "doc".to_string(),
8215            content: vec![AdfNode::paragraph(vec![AdfNode::emoji("thumbsup")])],
8216        };
8217        let md = adf_to_markdown(&doc).unwrap();
8218        assert!(md.contains(":thumbsup:"));
8219    }
8220
8221    #[test]
8222    fn adf_emoji_with_colon_prefix_to_markdown() {
8223        // JIRA stores shortName as ":thumbsup:" with colons
8224        let doc = AdfDocument {
8225            version: 1,
8226            doc_type: "doc".to_string(),
8227            content: vec![AdfNode::paragraph(vec![AdfNode {
8228                node_type: "emoji".to_string(),
8229                attrs: Some(serde_json::json!({"shortName": ":thumbsup:"})),
8230                content: None,
8231                text: None,
8232                marks: None,
8233                local_id: None,
8234                parameters: None,
8235            }])],
8236        };
8237        let md = adf_to_markdown(&doc).unwrap();
8238        assert!(md.contains(":thumbsup:"));
8239        // Should not produce ::thumbsup:: (double colons)
8240        assert!(!md.contains("::thumbsup::"));
8241    }
8242
8243    #[test]
8244    fn round_trip_emoji() {
8245        let md = "Hello :wave: world\n";
8246        let doc = markdown_to_adf(md).unwrap();
8247        let result = adf_to_markdown(&doc).unwrap();
8248        assert!(result.contains(":wave:"));
8249    }
8250
8251    #[test]
8252    fn emoji_with_id_and_text_round_trips() {
8253        let doc = AdfDocument {
8254            version: 1,
8255            doc_type: "doc".to_string(),
8256            content: vec![AdfNode::paragraph(vec![AdfNode {
8257                node_type: "emoji".to_string(),
8258                attrs: Some(
8259                    serde_json::json!({"shortName": ":check_mark:", "id": "2705", "text": "✅"}),
8260                ),
8261                content: None,
8262                text: None,
8263                marks: None,
8264                local_id: None,
8265                parameters: None,
8266            }])],
8267        };
8268        let md = adf_to_markdown(&doc).unwrap();
8269        assert!(md.contains(":check_mark:"), "shortcode present: {md}");
8270        assert!(md.contains("id="), "id attr present: {md}");
8271        assert!(md.contains("text="), "text attr present: {md}");
8272
8273        // Round-trip back to ADF
8274        let round_tripped = markdown_to_adf(&md).unwrap();
8275        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8276        let attrs = emoji.attrs.as_ref().unwrap();
8277        assert_eq!(attrs["shortName"], ":check_mark:");
8278        assert_eq!(attrs["id"], "2705");
8279        assert_eq!(attrs["text"], "✅");
8280    }
8281
8282    #[test]
8283    fn emoji_without_extra_attrs_still_works() {
8284        let md = "Hello :wave: world\n";
8285        let doc = markdown_to_adf(md).unwrap();
8286        let emoji = &doc.content[0].content.as_ref().unwrap()[1];
8287        assert_eq!(emoji.attrs.as_ref().unwrap()["shortName"], ":wave:");
8288        // No id or text attrs when not provided
8289        assert!(emoji.attrs.as_ref().unwrap().get("id").is_none());
8290    }
8291
8292    #[test]
8293    fn emoji_shortname_preserves_colons_round_trip() {
8294        // Issue #362: emoji shortName colons stripped during round-trip
8295        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8296          {"type":"emoji","attrs":{"shortName":":cross_mark:","id":"atlassian-cross_mark","text":"❌"}}
8297        ]}]}"#;
8298        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8299
8300        // ADF → markdown → ADF round-trip
8301        let md = adf_to_markdown(&doc).unwrap();
8302        let round_tripped = markdown_to_adf(&md).unwrap();
8303        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8304        let attrs = emoji.attrs.as_ref().unwrap();
8305        assert_eq!(
8306            attrs["shortName"], ":cross_mark:",
8307            "shortName should preserve colons, got: {}",
8308            attrs["shortName"]
8309        );
8310        assert_eq!(attrs["id"], "atlassian-cross_mark");
8311        assert_eq!(attrs["text"], "❌");
8312    }
8313
8314    #[test]
8315    fn emoji_shortname_without_colons_preserved() {
8316        // Issue #379: shortName without colons should not gain colons
8317        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8318          {"type":"emoji","attrs":{"shortName":"white_check_mark","id":"2705","text":"✅"}}
8319        ]}]}"#;
8320        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8321        let md = adf_to_markdown(&doc).unwrap();
8322        let round_tripped = markdown_to_adf(&md).unwrap();
8323        let emoji = &round_tripped.content[0].content.as_ref().unwrap()[0];
8324        let attrs = emoji.attrs.as_ref().unwrap();
8325        assert_eq!(
8326            attrs["shortName"], "white_check_mark",
8327            "shortName without colons should stay without colons, got: {}",
8328            attrs["shortName"]
8329        );
8330    }
8331
8332    #[test]
8333    fn colon_in_text_not_emoji() {
8334        // A lone colon should not trigger emoji parsing
8335        let doc = markdown_to_adf("Time is 10:30 today").unwrap();
8336        let content = doc.content[0].content.as_ref().unwrap();
8337        assert_eq!(content.len(), 1);
8338        assert_eq!(content[0].node_type, "text");
8339    }
8340
8341    #[test]
8342    fn text_with_shortcode_pattern_round_trips_as_text() {
8343        // Issue #450: `:fire:` in a text node must not become an emoji node
8344        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Alert :fire: triggered on pod:pod42"}]}]}"#;
8345        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8346
8347        let md = adf_to_markdown(&doc).unwrap();
8348        let round_tripped = markdown_to_adf(&md).unwrap();
8349        let content = round_tripped.content[0].content.as_ref().unwrap();
8350
8351        assert_eq!(
8352            content.len(),
8353            1,
8354            "should be a single text node, got: {content:?}"
8355        );
8356        assert_eq!(content[0].node_type, "text");
8357        assert_eq!(
8358            content[0].text.as_deref().unwrap(),
8359            "Alert :fire: triggered on pod:pod42"
8360        );
8361    }
8362
8363    #[test]
8364    fn double_colon_pattern_round_trips_as_text() {
8365        // Issue #450: `::Active::` should not be parsed as emoji `:Active:`
8366        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Status::Active::Running"}]}]}"#;
8367        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8368
8369        let md = adf_to_markdown(&doc).unwrap();
8370        let round_tripped = markdown_to_adf(&md).unwrap();
8371        let content = round_tripped.content[0].content.as_ref().unwrap();
8372
8373        assert_eq!(
8374            content.len(),
8375            1,
8376            "should be a single text node, got: {content:?}"
8377        );
8378        assert_eq!(content[0].node_type, "text");
8379        assert_eq!(
8380            content[0].text.as_deref().unwrap(),
8381            "Status::Active::Running"
8382        );
8383    }
8384
8385    #[test]
8386    fn real_emoji_node_still_round_trips() {
8387        // Ensure actual emoji ADF nodes still work after the escaping fix
8388        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8389          {"type":"text","text":"Hello "},
8390          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8391          {"type":"text","text":" world"}
8392        ]}]}"#;
8393        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8394
8395        let md = adf_to_markdown(&doc).unwrap();
8396        let round_tripped = markdown_to_adf(&md).unwrap();
8397        let content = round_tripped.content[0].content.as_ref().unwrap();
8398
8399        // Should have: text("Hello ") + emoji(:fire:) + text(" world")
8400        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8401        assert_eq!(content[0].text.as_deref(), Some("Hello "));
8402        assert_eq!(content[1].node_type, "emoji");
8403        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":fire:");
8404        assert_eq!(content[2].text.as_deref(), Some(" world"));
8405    }
8406
8407    #[test]
8408    fn combined_emoji_shortname_round_trips_as_single_node() {
8409        // Issue #576: an emoji node whose shortName is a combination of two
8410        // shortcodes (e.g. ":slightly_smiling_face::bow:") must survive the
8411        // ADF → markdown → ADF round-trip as a single emoji node rather than
8412        // being split into two.
8413        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8414          {"type":"text","text":"Thanks for the help "},
8415          {"type":"emoji","attrs":{"shortName":":slightly_smiling_face::bow:","id":"","text":""}}
8416        ]}]}"#;
8417        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8418
8419        let md = adf_to_markdown(&doc).unwrap();
8420        let round_tripped = markdown_to_adf(&md).unwrap();
8421        let content = round_tripped.content[0].content.as_ref().unwrap();
8422
8423        assert_eq!(
8424            content.len(),
8425            2,
8426            "should have text + single combined emoji: {content:?}"
8427        );
8428        assert_eq!(content[0].text.as_deref(), Some("Thanks for the help "));
8429        assert_eq!(content[1].node_type, "emoji");
8430        let attrs = content[1].attrs.as_ref().unwrap();
8431        assert_eq!(attrs["shortName"], ":slightly_smiling_face::bow:");
8432        assert_eq!(attrs["id"], "");
8433        assert_eq!(attrs["text"], "");
8434    }
8435
8436    #[test]
8437    fn triple_combined_emoji_shortname_round_trips_as_single_node() {
8438        // Three-part combined shortName must also survive round-trip.
8439        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8440          {"type":"emoji","attrs":{"shortName":":a::b::c:","id":"x","text":""}}
8441        ]}]}"#;
8442        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8443
8444        let md = adf_to_markdown(&doc).unwrap();
8445        let round_tripped = markdown_to_adf(&md).unwrap();
8446        let content = round_tripped.content[0].content.as_ref().unwrap();
8447
8448        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8449        assert_eq!(content[0].node_type, "emoji");
8450        let attrs = content[0].attrs.as_ref().unwrap();
8451        assert_eq!(attrs["shortName"], ":a::b::c:");
8452        assert_eq!(attrs["id"], "x");
8453    }
8454
8455    #[test]
8456    fn consecutive_emojis_remain_separate_nodes() {
8457        // Two independent emoji nodes (each with their own directive) must
8458        // remain two separate nodes — the combined-chain logic must not
8459        // swallow the second emoji.
8460        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8461          {"type":"emoji","attrs":{"shortName":":fire:","id":"1f525","text":"🔥"}},
8462          {"type":"emoji","attrs":{"shortName":":water:","id":"1f4a7","text":"💧"}}
8463        ]}]}"#;
8464        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8465
8466        let md = adf_to_markdown(&doc).unwrap();
8467        let round_tripped = markdown_to_adf(&md).unwrap();
8468        let content = round_tripped.content[0].content.as_ref().unwrap();
8469
8470        assert_eq!(content.len(), 2, "should be two emoji nodes: {content:?}");
8471        assert_eq!(content[0].node_type, "emoji");
8472        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8473        assert_eq!(content[1].node_type, "emoji");
8474        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8475    }
8476
8477    #[test]
8478    fn adjacent_shortcodes_without_directive_parse_as_two_emojis() {
8479        // Raw markdown with two adjacent shortcodes and no directive should
8480        // still parse as two separate emoji nodes.
8481        let md = ":fire::water:";
8482        let doc = markdown_to_adf(md).unwrap();
8483        let content = doc.content[0].content.as_ref().unwrap();
8484
8485        assert_eq!(content.len(), 2, "should be two emojis: {content:?}");
8486        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":fire:");
8487        assert_eq!(content[1].attrs.as_ref().unwrap()["shortName"], ":water:");
8488    }
8489
8490    #[test]
8491    fn combined_emoji_shortname_preserves_local_id() {
8492        // The directive's localId should be preserved when the chain is
8493        // collapsed into a single emoji node.
8494        let md = r#":a::b:{shortName=":a::b:" id="x" text="y" localId="abc"}"#;
8495        let doc = markdown_to_adf(md).unwrap();
8496        let content = doc.content[0].content.as_ref().unwrap();
8497
8498        assert_eq!(content.len(), 1, "should be single emoji: {content:?}");
8499        let attrs = content[0].attrs.as_ref().unwrap();
8500        assert_eq!(attrs["shortName"], ":a::b:");
8501        assert_eq!(attrs["id"], "x");
8502        assert_eq!(attrs["text"], "y");
8503        assert_eq!(attrs["localId"], "abc");
8504    }
8505
8506    #[test]
8507    fn text_shortcode_with_marks_round_trips() {
8508        // Bold text containing an emoji-like shortcode should round-trip as bold text
8509        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8510          {"type":"text","text":"Alert :fire: triggered","marks":[{"type":"strong"}]}
8511        ]}]}"#;
8512        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8513
8514        let md = adf_to_markdown(&doc).unwrap();
8515        let round_tripped = markdown_to_adf(&md).unwrap();
8516        let content = round_tripped.content[0].content.as_ref().unwrap();
8517
8518        assert_eq!(
8519            content.len(),
8520            1,
8521            "should be single bold text node: {content:?}"
8522        );
8523        assert_eq!(content[0].node_type, "text");
8524        assert_eq!(
8525            content[0].text.as_deref().unwrap(),
8526            "Alert :fire: triggered"
8527        );
8528        assert!(content[0]
8529            .marks
8530            .as_ref()
8531            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong")));
8532    }
8533
8534    #[test]
8535    fn mixed_emoji_node_and_text_shortcode_round_trips() {
8536        // Real emoji node adjacent to text containing a shortcode-like pattern
8537        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8538          {"type":"emoji","attrs":{"shortName":":wave:","id":"1f44b","text":"👋"}},
8539          {"type":"text","text":" says :hello: to you"}
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        let content = round_tripped.content[0].content.as_ref().unwrap();
8546
8547        // Should be: emoji(:wave:) + text(" says :hello: to you")
8548        assert_eq!(content.len(), 2, "should have 2 nodes: {content:?}");
8549        assert_eq!(content[0].node_type, "emoji");
8550        assert_eq!(content[0].attrs.as_ref().unwrap()["shortName"], ":wave:");
8551        assert_eq!(content[1].node_type, "text");
8552        assert_eq!(content[1].text.as_deref().unwrap(), " says :hello: to you");
8553    }
8554
8555    #[test]
8556    fn code_block_with_shortcode_pattern_round_trips() {
8557        // Issue #552: Code block content containing `::Name::` patterns must not
8558        // be re-parsed as emoji shortcodes.
8559        let adf_json = r#"{"version":1,"type":"doc","content":[
8560          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8561            {"type":"text","text":"module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"}
8562          ]}
8563        ]}"#;
8564        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8565
8566        let md = adf_to_markdown(&doc).unwrap();
8567        let round_tripped = markdown_to_adf(&md).unwrap();
8568
8569        assert_eq!(
8570            round_tripped.content.len(),
8571            1,
8572            "should be a single codeBlock"
8573        );
8574        let cb = &round_tripped.content[0];
8575        assert_eq!(cb.node_type, "codeBlock");
8576        let content = cb.content.as_ref().expect("codeBlock content");
8577        assert_eq!(
8578            content.len(),
8579            1,
8580            "should be a single text node: {content:?}"
8581        );
8582        assert_eq!(content[0].node_type, "text");
8583        assert_eq!(
8584            content[0].text.as_deref().unwrap(),
8585            "module Foo::Bar::Baz\n  def hello\n    puts 'world'\n  end\nend"
8586        );
8587        assert!(
8588            content.iter().all(|n| n.node_type != "emoji"),
8589            "no emoji nodes should be present, got: {content:?}"
8590        );
8591    }
8592
8593    #[test]
8594    fn code_block_with_exact_namespaced_shortcode_pattern_round_trips() {
8595        // Issue #552: Use a namespaced pattern modeled on the bug report.
8596        let adf_json = r#"{"version":1,"type":"doc","content":[
8597          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8598            {"type":"text","text":"class ZBC::Acme::PlanType::Professional < Base"}
8599          ]}
8600        ]}"#;
8601        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8602
8603        let md = adf_to_markdown(&doc).unwrap();
8604        let round_tripped = markdown_to_adf(&md).unwrap();
8605
8606        let cb = &round_tripped.content[0];
8607        assert_eq!(cb.node_type, "codeBlock");
8608        let content = cb.content.as_ref().expect("codeBlock content");
8609        assert_eq!(content.len(), 1, "should be a single text node");
8610        assert_eq!(
8611            content[0].text.as_deref().unwrap(),
8612            "class ZBC::Acme::PlanType::Professional < Base"
8613        );
8614    }
8615
8616    #[test]
8617    fn code_block_with_literal_shortcode_round_trips() {
8618        // Issue #552: Content that is exactly a shortcode (`:fire:`) inside a
8619        // code block should survive the round-trip as literal text, not emoji.
8620        let adf_json = r#"{"version":1,"type":"doc","content":[
8621          {"type":"codeBlock","attrs":{"language":"text"},"content":[
8622            {"type":"text","text":":fire: :wave: :thumbsup:"}
8623          ]}
8624        ]}"#;
8625        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8626
8627        let md = adf_to_markdown(&doc).unwrap();
8628        let round_tripped = markdown_to_adf(&md).unwrap();
8629
8630        let cb = &round_tripped.content[0];
8631        assert_eq!(cb.node_type, "codeBlock");
8632        let content = cb.content.as_ref().expect("codeBlock content");
8633        assert_eq!(
8634            content.len(),
8635            1,
8636            "should be a single text node: {content:?}"
8637        );
8638        assert_eq!(content[0].node_type, "text");
8639        assert_eq!(
8640            content[0].text.as_deref().unwrap(),
8641            ":fire: :wave: :thumbsup:"
8642        );
8643    }
8644
8645    #[test]
8646    fn inline_code_with_shortcode_pattern_round_trips() {
8647        // Issue #552: Inline code containing `::Name::` patterns must not be
8648        // re-parsed as emoji shortcodes.
8649        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8650          {"type":"text","text":"See "},
8651          {"type":"text","text":"Foo::Bar::Baz","marks":[{"type":"code"}]},
8652          {"type":"text","text":" for details"}
8653        ]}]}"#;
8654        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8655
8656        let md = adf_to_markdown(&doc).unwrap();
8657        let round_tripped = markdown_to_adf(&md).unwrap();
8658        let content = round_tripped.content[0].content.as_ref().unwrap();
8659
8660        assert_eq!(content.len(), 3, "should have 3 text nodes: {content:?}");
8661        assert_eq!(content[0].text.as_deref(), Some("See "));
8662        assert_eq!(content[1].text.as_deref(), Some("Foo::Bar::Baz"));
8663        assert!(content[1]
8664            .marks
8665            .as_ref()
8666            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8667        assert_eq!(content[2].text.as_deref(), Some(" for details"));
8668        assert!(
8669            content.iter().all(|n| n.node_type != "emoji"),
8670            "no emoji nodes should be present"
8671        );
8672    }
8673
8674    #[test]
8675    fn inline_code_with_literal_shortcode_round_trips() {
8676        // Issue #552: Inline code whose content is exactly a shortcode must be
8677        // preserved as code, not converted to an emoji.
8678        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8679          {"type":"text","text":":fire:","marks":[{"type":"code"}]}
8680        ]}]}"#;
8681        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8682
8683        let md = adf_to_markdown(&doc).unwrap();
8684        let round_tripped = markdown_to_adf(&md).unwrap();
8685        let content = round_tripped.content[0].content.as_ref().unwrap();
8686
8687        assert_eq!(
8688            content.len(),
8689            1,
8690            "should be a single code node: {content:?}"
8691        );
8692        assert_eq!(content[0].node_type, "text");
8693        assert_eq!(content[0].text.as_deref(), Some(":fire:"));
8694        assert!(content[0]
8695            .marks
8696            .as_ref()
8697            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8698    }
8699
8700    #[test]
8701    fn code_block_in_list_with_shortcode_pattern_round_trips() {
8702        // Issue #552: A code block containing shortcode-like patterns nested in
8703        // a list should still survive round-trip without emoji corruption.
8704        let adf_json = r#"{"version":1,"type":"doc","content":[
8705          {"type":"bulletList","content":[
8706            {"type":"listItem","content":[
8707              {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8708                {"type":"text","text":"Foo::Bar::Baz"}
8709              ]}
8710            ]}
8711          ]}
8712        ]}"#;
8713        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8714
8715        let md = adf_to_markdown(&doc).unwrap();
8716        let round_tripped = markdown_to_adf(&md).unwrap();
8717
8718        let list = &round_tripped.content[0];
8719        assert_eq!(list.node_type, "bulletList");
8720        let item = &list.content.as_ref().unwrap()[0];
8721        assert_eq!(item.node_type, "listItem");
8722        let cb = &item.content.as_ref().unwrap()[0];
8723        assert_eq!(cb.node_type, "codeBlock");
8724        let cb_content = cb.content.as_ref().unwrap();
8725        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8726        assert_eq!(cb_content[0].node_type, "text");
8727    }
8728
8729    #[test]
8730    fn code_block_with_unicode_shortcode_pattern_round_trips() {
8731        // Issue #552: Code block content with non-ASCII colon-delimited words
8732        // (e.g. CJK or accented characters) must round-trip without splitting
8733        // or emoji corruption.
8734        let adf_json = r#"{"version":1,"type":"doc","content":[
8735          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8736            {"type":"text","text":"class ZBC::配置::Production < Base"}
8737          ]}
8738        ]}"#;
8739        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8740
8741        let md = adf_to_markdown(&doc).unwrap();
8742        let round_tripped = markdown_to_adf(&md).unwrap();
8743
8744        let cb = &round_tripped.content[0];
8745        assert_eq!(cb.node_type, "codeBlock");
8746        let content = cb.content.as_ref().expect("codeBlock content");
8747        assert_eq!(content.len(), 1);
8748        assert_eq!(
8749            content[0].text.as_deref().unwrap(),
8750            "class ZBC::配置::Production < Base"
8751        );
8752    }
8753
8754    #[test]
8755    fn list_item_hardbreak_then_code_block_round_trips() {
8756        // Issue #552: A list item whose first paragraph ends with a hardBreak
8757        // followed by a codeBlock must round-trip correctly.  Previously, the
8758        // hardBreak's `\` continuation swallowed the 2-space-indented code
8759        // fence line, turning the whole block into a paragraph where `::Bar::`
8760        // was re-parsed as an emoji.
8761        let adf_json = r#"{"version":1,"type":"doc","content":[
8762          {"type":"bulletList","content":[
8763            {"type":"listItem","content":[
8764              {"type":"paragraph","content":[
8765                {"type":"text","text":"Consider removing this check."},
8766                {"type":"hardBreak"}
8767              ]},
8768              {"type":"codeBlock","content":[
8769                {"type":"text","text":"x = Foo::Bar::Baz.new"}
8770              ]}
8771            ]}
8772          ]}
8773        ]}"#;
8774        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8775
8776        let md = adf_to_markdown(&doc).unwrap();
8777        let round_tripped = markdown_to_adf(&md).unwrap();
8778
8779        let list = &round_tripped.content[0];
8780        assert_eq!(list.node_type, "bulletList");
8781        let item = &list.content.as_ref().unwrap()[0];
8782        assert_eq!(item.node_type, "listItem");
8783        let item_content = item.content.as_ref().unwrap();
8784        assert_eq!(
8785            item_content.len(),
8786            2,
8787            "listItem should have paragraph + codeBlock, got: {item_content:?}"
8788        );
8789        assert_eq!(item_content[0].node_type, "paragraph");
8790        assert_eq!(item_content[1].node_type, "codeBlock");
8791
8792        // The code block text must survive verbatim — no emoji splitting.
8793        let cb_content = item_content[1].content.as_ref().unwrap();
8794        assert_eq!(cb_content[0].node_type, "text");
8795        assert_eq!(
8796            cb_content[0].text.as_deref().unwrap(),
8797            "x = Foo::Bar::Baz.new"
8798        );
8799
8800        // And there should be no emoji node anywhere in the tree.
8801        assert!(
8802            item_content
8803                .iter()
8804                .flat_map(|n| n.content.iter().flat_map(|c| c.iter()))
8805                .all(|n| n.node_type != "emoji"),
8806            "no emoji nodes should be present, got: {item_content:?}"
8807        );
8808    }
8809
8810    #[test]
8811    fn list_item_hardbreak_then_nested_list_still_works() {
8812        // Ensure the hardBreak continuation fix doesn't break nested list
8813        // handling — an indented `- item` line after a hardBreak is still a
8814        // nested list, not a code fence.
8815        let md = "- first\\\n  continuation text\\\n  - nested item\n";
8816        let doc = markdown_to_adf(md).unwrap();
8817        let list = &doc.content[0];
8818        assert_eq!(list.node_type, "bulletList");
8819        let item = &list.content.as_ref().unwrap()[0];
8820        // First paragraph should contain the continuation text joined via hardBreaks
8821        let item_content = item.content.as_ref().unwrap();
8822        let para = &item_content[0];
8823        assert_eq!(para.node_type, "paragraph");
8824        let para_nodes = para.content.as_ref().unwrap();
8825        assert!(
8826            para_nodes
8827                .iter()
8828                .any(|n| n.text.as_deref() == Some("continuation text")),
8829            "continuation text should survive: {para_nodes:?}"
8830        );
8831    }
8832
8833    #[test]
8834    fn list_item_hardbreak_then_image_still_works() {
8835        // Regression check for issue #490: the image-skip behaviour in
8836        // collect_hardbreak_continuations must still hold after the code-fence
8837        // fix.  The `![](url)` line must remain a block-level mediaSingle, not
8838        // be swallowed into the paragraph.
8839        let md = "- first\\\n  ![](https://example.com/x.png){type=file id=x}\n";
8840        let doc = markdown_to_adf(md).unwrap();
8841        let list = &doc.content[0];
8842        let item = &list.content.as_ref().unwrap()[0];
8843        let item_content = item.content.as_ref().unwrap();
8844        // The image should be a sibling block, not part of the first paragraph.
8845        assert!(
8846            item_content.iter().any(|n| n.node_type == "mediaSingle"),
8847            "mediaSingle should still be a block-level sibling, got: {item_content:?}"
8848        );
8849    }
8850
8851    #[test]
8852    fn list_item_hardbreak_then_container_directive_round_trips() {
8853        // Issue #552: Same hardBreak-swallows-block-siblings bug class — a
8854        // container directive (`:::panel`) after a hardBreak must also not be
8855        // consumed as a continuation line.
8856        let adf_json = r#"{"version":1,"type":"doc","content":[
8857          {"type":"bulletList","content":[
8858            {"type":"listItem","content":[
8859              {"type":"paragraph","content":[
8860                {"type":"text","text":"intro"},
8861                {"type":"hardBreak"}
8862              ]},
8863              {"type":"panel","attrs":{"panelType":"info"},"content":[
8864                {"type":"paragraph","content":[
8865                  {"type":"text","text":"panel body"}
8866                ]}
8867              ]}
8868            ]}
8869          ]}
8870        ]}"#;
8871        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8872        let md = adf_to_markdown(&doc).unwrap();
8873        let round_tripped = markdown_to_adf(&md).unwrap();
8874
8875        let item = &round_tripped.content[0].content.as_ref().unwrap()[0];
8876        let item_content = item.content.as_ref().unwrap();
8877        assert!(
8878            item_content.iter().any(|n| n.node_type == "panel"),
8879            "panel should survive as block-level sibling, got: {item_content:?}"
8880        );
8881    }
8882
8883    #[test]
8884    fn inline_code_with_unicode_shortcode_pattern_round_trips() {
8885        // Issue #552: Inline code containing non-ASCII colon-delimited words
8886        // must round-trip without emoji corruption.
8887        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
8888          {"type":"text","text":"See "},
8889          {"type":"text","text":"ZBC::配置::Production","marks":[{"type":"code"}]},
8890          {"type":"text","text":" for prod"}
8891        ]}]}"#;
8892        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8893
8894        let md = adf_to_markdown(&doc).unwrap();
8895        let round_tripped = markdown_to_adf(&md).unwrap();
8896        let content = round_tripped.content[0].content.as_ref().unwrap();
8897
8898        assert_eq!(content.len(), 3, "should have 3 nodes: {content:?}");
8899        assert_eq!(content[1].text.as_deref(), Some("ZBC::配置::Production"));
8900        assert!(content[1]
8901            .marks
8902            .as_ref()
8903            .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "code")));
8904    }
8905
8906    #[test]
8907    fn code_block_followed_by_shortcode_text_round_trips() {
8908        // Issue #552: A code block with colon-delimited content followed by a
8909        // paragraph containing emoji-like text should not confuse parsing.
8910        let adf_json = r#"{"version":1,"type":"doc","content":[
8911          {"type":"codeBlock","attrs":{"language":"ruby"},"content":[
8912            {"type":"text","text":"Foo::Bar::Baz"}
8913          ]},
8914          {"type":"paragraph","content":[
8915            {"type":"text","text":"Status::Active::Running"}
8916          ]}
8917        ]}"#;
8918        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
8919
8920        let md = adf_to_markdown(&doc).unwrap();
8921        let round_tripped = markdown_to_adf(&md).unwrap();
8922
8923        assert_eq!(round_tripped.content.len(), 2);
8924        let cb = &round_tripped.content[0];
8925        assert_eq!(cb.node_type, "codeBlock");
8926        let cb_content = cb.content.as_ref().unwrap();
8927        assert_eq!(cb_content[0].text.as_deref(), Some("Foo::Bar::Baz"));
8928
8929        let para = &round_tripped.content[1];
8930        assert_eq!(para.node_type, "paragraph");
8931        let para_content = para.content.as_ref().unwrap();
8932        assert_eq!(para_content.len(), 1);
8933        assert_eq!(para_content[0].node_type, "text");
8934        assert_eq!(
8935            para_content[0].text.as_deref(),
8936            Some("Status::Active::Running")
8937        );
8938    }
8939
8940    #[test]
8941    fn adf_inline_card_to_markdown() {
8942        let doc = AdfDocument {
8943            version: 1,
8944            doc_type: "doc".to_string(),
8945            content: vec![AdfNode::paragraph(vec![AdfNode {
8946                node_type: "inlineCard".to_string(),
8947                attrs: Some(
8948                    serde_json::json!({"url": "https://org.atlassian.net/browse/ACCS-4382"}),
8949                ),
8950                content: None,
8951                text: None,
8952                marks: None,
8953                local_id: None,
8954                parameters: None,
8955            }])],
8956        };
8957        let md = adf_to_markdown(&doc).unwrap();
8958        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8959        assert!(!md.contains("<!-- unsupported inline"));
8960    }
8961
8962    #[test]
8963    fn inline_card_directive_round_trips() {
8964        // inlineCard → :card[url] → inlineCard
8965        let original = AdfDocument {
8966            version: 1,
8967            doc_type: "doc".to_string(),
8968            content: vec![AdfNode::paragraph(vec![AdfNode::inline_card(
8969                "https://org.atlassian.net/browse/ACCS-4382",
8970            )])],
8971        };
8972        let md = adf_to_markdown(&original).unwrap();
8973        assert!(md.contains(":card[https://org.atlassian.net/browse/ACCS-4382]"));
8974        let restored = markdown_to_adf(&md).unwrap();
8975        let node = &restored.content[0].content.as_ref().unwrap()[0];
8976        assert_eq!(node.node_type, "inlineCard");
8977        assert_eq!(
8978            node.attrs.as_ref().unwrap()["url"],
8979            "https://org.atlassian.net/browse/ACCS-4382"
8980        );
8981    }
8982
8983    #[test]
8984    fn inline_card_directive_parsed_from_jfm() {
8985        // :card[url] in JFM → inlineCard in ADF
8986        let doc = markdown_to_adf("See :card[https://example.com/issue/123] for details.").unwrap();
8987        let nodes = doc.content[0].content.as_ref().unwrap();
8988        assert_eq!(nodes[0].node_type, "text");
8989        assert_eq!(nodes[0].text.as_deref(), Some("See "));
8990        assert_eq!(nodes[1].node_type, "inlineCard");
8991        assert_eq!(
8992            nodes[1].attrs.as_ref().unwrap()["url"],
8993            "https://example.com/issue/123"
8994        );
8995        assert_eq!(nodes[2].node_type, "text");
8996        assert_eq!(nodes[2].text.as_deref(), Some(" for details."));
8997    }
8998
8999    #[test]
9000    fn self_link_becomes_link_mark_not_inline_card() {
9001        // Issue #378: [url](url) should produce a link mark, not inlineCard.
9002        // inlineCard is only for :card[url] directives and bare URLs.
9003        let doc = markdown_to_adf("[https://example.com](https://example.com)").unwrap();
9004        let node = &doc.content[0].content.as_ref().unwrap()[0];
9005        assert_eq!(node.node_type, "text");
9006        assert_eq!(node.text.as_deref(), Some("https://example.com"));
9007        let mark = &node.marks.as_ref().unwrap()[0];
9008        assert_eq!(mark.mark_type, "link");
9009        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
9010    }
9011
9012    #[test]
9013    fn url_link_text_with_trailing_slash_mismatch_becomes_link_mark() {
9014        // Issue #523: [url](url/) where text and href differ only by trailing
9015        // slash should produce a text node with link mark, not an inlineCard.
9016        let doc =
9017            markdown_to_adf("[https://octopz.example.com](https://octopz.example.com/)").unwrap();
9018        let node = &doc.content[0].content.as_ref().unwrap()[0];
9019        assert_eq!(node.node_type, "text");
9020        assert_eq!(node.text.as_deref(), Some("https://octopz.example.com"));
9021        let mark = &node.marks.as_ref().unwrap()[0];
9022        assert_eq!(mark.mark_type, "link");
9023        assert_eq!(
9024            mark.attrs.as_ref().unwrap()["href"],
9025            "https://octopz.example.com/"
9026        );
9027    }
9028
9029    #[test]
9030    fn named_link_does_not_become_inline_card() {
9031        // [#4668](url) — text differs from url, stays as a link mark
9032        let doc = markdown_to_adf("[#4668](https://github.com/org/repo/pull/4668)").unwrap();
9033        let node = &doc.content[0].content.as_ref().unwrap()[0];
9034        assert_eq!(node.node_type, "text");
9035        assert_eq!(node.text.as_deref(), Some("#4668"));
9036        let mark = &node.marks.as_ref().unwrap()[0];
9037        assert_eq!(mark.mark_type, "link");
9038    }
9039
9040    #[test]
9041    fn adf_inline_card_no_url_to_markdown() {
9042        let doc = AdfDocument {
9043            version: 1,
9044            doc_type: "doc".to_string(),
9045            content: vec![AdfNode::paragraph(vec![AdfNode {
9046                node_type: "inlineCard".to_string(),
9047                attrs: Some(serde_json::json!({})),
9048                content: None,
9049                text: None,
9050                marks: None,
9051                local_id: None,
9052                parameters: None,
9053            }])],
9054        };
9055        let md = adf_to_markdown(&doc).unwrap();
9056        // No url attr — renders nothing (not a comment)
9057        assert!(!md.contains("<!-- unsupported inline"));
9058    }
9059
9060    #[test]
9061    fn adf_code_block_no_language_to_markdown() {
9062        let doc = AdfDocument {
9063            version: 1,
9064            doc_type: "doc".to_string(),
9065            content: vec![AdfNode::code_block(None, "plain code")],
9066        };
9067        let md = adf_to_markdown(&doc).unwrap();
9068        assert!(md.contains("```\n"));
9069        assert!(md.contains("plain code"));
9070    }
9071
9072    #[test]
9073    fn adf_code_block_empty_language_to_markdown() {
9074        let doc = AdfDocument {
9075            version: 1,
9076            doc_type: "doc".to_string(),
9077            content: vec![AdfNode::code_block(Some(""), "plain code")],
9078        };
9079        let md = adf_to_markdown(&doc).unwrap();
9080        assert!(md.contains("```\"\"\n"));
9081        assert!(md.contains("plain code"));
9082    }
9083
9084    // ── Additional round-trip tests ────────────────────────────────────
9085
9086    #[test]
9087    fn round_trip_table() {
9088        let md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
9089        let adf = markdown_to_adf(md).unwrap();
9090        let restored = adf_to_markdown(&adf).unwrap();
9091        assert!(restored.contains("| A | B |"));
9092        assert!(restored.contains("| 1 | 2 |"));
9093    }
9094
9095    #[test]
9096    fn round_trip_blockquote() {
9097        let md = "> This is quoted\n";
9098        let adf = markdown_to_adf(md).unwrap();
9099        let restored = adf_to_markdown(&adf).unwrap();
9100        assert!(restored.contains("> This is quoted"));
9101    }
9102
9103    #[test]
9104    fn round_trip_image() {
9105        let md = "![Logo](https://example.com/logo.png)\n";
9106        let adf = markdown_to_adf(md).unwrap();
9107        let restored = adf_to_markdown(&adf).unwrap();
9108        assert!(restored.contains("![Logo](https://example.com/logo.png)"));
9109    }
9110
9111    #[test]
9112    fn round_trip_ordered_list() {
9113        let md = "1. A\n2. B\n3. C\n";
9114        let adf = markdown_to_adf(md).unwrap();
9115        let restored = adf_to_markdown(&adf).unwrap();
9116        assert!(restored.contains("1. A"));
9117        assert!(restored.contains("2. B"));
9118        assert!(restored.contains("3. C"));
9119    }
9120
9121    #[test]
9122    fn round_trip_inline_marks() {
9123        let md = "Text with `code` and ~~strike~~ and [link](https://x.com).\n";
9124        let adf = markdown_to_adf(md).unwrap();
9125        let restored = adf_to_markdown(&adf).unwrap();
9126        assert!(restored.contains("`code`"));
9127        assert!(restored.contains("~~strike~~"));
9128        assert!(restored.contains("[link](https://x.com)"));
9129    }
9130
9131    // ── Container directive tests (Tier 2) ───────────────────────────
9132
9133    #[test]
9134    fn panel_info() {
9135        let md = ":::panel{type=info}\nThis is informational.\n:::";
9136        let doc = markdown_to_adf(md).unwrap();
9137        assert_eq!(doc.content[0].node_type, "panel");
9138        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["panelType"], "info");
9139        let inner = doc.content[0].content.as_ref().unwrap();
9140        assert_eq!(inner[0].node_type, "paragraph");
9141    }
9142
9143    #[test]
9144    fn adf_panel_to_markdown() {
9145        let doc = AdfDocument {
9146            version: 1,
9147            doc_type: "doc".to_string(),
9148            content: vec![AdfNode::panel(
9149                "warning",
9150                vec![AdfNode::paragraph(vec![AdfNode::text("Be careful.")])],
9151            )],
9152        };
9153        let md = adf_to_markdown(&doc).unwrap();
9154        assert!(md.contains(":::panel{type=warning}"));
9155        assert!(md.contains("Be careful."));
9156        assert!(md.contains(":::"));
9157    }
9158
9159    #[test]
9160    fn round_trip_panel() {
9161        let md = ":::panel{type=info}\nThis is informational.\n:::\n";
9162        let doc = markdown_to_adf(md).unwrap();
9163        let result = adf_to_markdown(&doc).unwrap();
9164        assert!(result.contains(":::panel{type=info}"));
9165        assert!(result.contains("This is informational."));
9166    }
9167
9168    #[test]
9169    fn expand_with_title() {
9170        let md = ":::expand{title=\"Click me\"}\nHidden content.\n:::";
9171        let doc = markdown_to_adf(md).unwrap();
9172        assert_eq!(doc.content[0].node_type, "expand");
9173        assert_eq!(doc.content[0].attrs.as_ref().unwrap()["title"], "Click me");
9174    }
9175
9176    #[test]
9177    fn adf_expand_to_markdown() {
9178        let doc = AdfDocument {
9179            version: 1,
9180            doc_type: "doc".to_string(),
9181            content: vec![AdfNode::expand(
9182                Some("Details"),
9183                vec![AdfNode::paragraph(vec![AdfNode::text("Inner.")])],
9184            )],
9185        };
9186        let md = adf_to_markdown(&doc).unwrap();
9187        assert!(md.contains(":::expand{title=\"Details\"}"));
9188        assert!(md.contains("Inner."));
9189    }
9190
9191    #[test]
9192    fn round_trip_expand() {
9193        let md = ":::expand{title=\"Details\"}\nInner content.\n:::\n";
9194        let doc = markdown_to_adf(md).unwrap();
9195        let result = adf_to_markdown(&doc).unwrap();
9196        assert!(result.contains(":::expand{title=\"Details\"}"));
9197        assert!(result.contains("Inner content."));
9198    }
9199
9200    #[test]
9201    fn layout_two_columns() {
9202        let md =
9203            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9204        let doc = markdown_to_adf(md).unwrap();
9205        assert_eq!(doc.content[0].node_type, "layoutSection");
9206        let columns = doc.content[0].content.as_ref().unwrap();
9207        assert_eq!(columns.len(), 2);
9208        assert_eq!(columns[0].node_type, "layoutColumn");
9209        assert_eq!(columns[1].node_type, "layoutColumn");
9210    }
9211
9212    #[test]
9213    fn adf_layout_to_markdown() {
9214        let doc = AdfDocument {
9215            version: 1,
9216            doc_type: "doc".to_string(),
9217            content: vec![AdfNode::layout_section(vec![
9218                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Left.")])]),
9219                AdfNode::layout_column(50, vec![AdfNode::paragraph(vec![AdfNode::text("Right.")])]),
9220            ])],
9221        };
9222        let md = adf_to_markdown(&doc).unwrap();
9223        assert!(md.contains("::::layout"));
9224        assert!(md.contains(":::column{width=50}"));
9225        assert!(md.contains("Left."));
9226        assert!(md.contains("Right."));
9227    }
9228
9229    #[test]
9230    fn layout_column_localid_roundtrip() {
9231        let adf_json = r#"{
9232            "version": 1,
9233            "type": "doc",
9234            "content": [{
9235                "type": "layoutSection",
9236                "content": [
9237                    {
9238                        "type": "layoutColumn",
9239                        "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9240                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Left"}]}]
9241                    },
9242                    {
9243                        "type": "layoutColumn",
9244                        "attrs": {"width": 50.0, "localId": "ddeeff445566"},
9245                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Right"}]}]
9246                    }
9247                ]
9248            }]
9249        }"#;
9250        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9251        let md = adf_to_markdown(&doc).unwrap();
9252        assert!(
9253            md.contains("localId=aabb112233cc"),
9254            "first column localId should appear in markdown: {md}"
9255        );
9256        assert!(
9257            md.contains("localId=ddeeff445566"),
9258            "second column localId should appear in markdown: {md}"
9259        );
9260        let rt = markdown_to_adf(&md).unwrap();
9261        let cols = rt.content[0].content.as_ref().unwrap();
9262        assert_eq!(
9263            cols[0].attrs.as_ref().unwrap()["localId"],
9264            "aabb112233cc",
9265            "first column localId should round-trip"
9266        );
9267        assert_eq!(
9268            cols[1].attrs.as_ref().unwrap()["localId"],
9269            "ddeeff445566",
9270            "second column localId should round-trip"
9271        );
9272    }
9273
9274    #[test]
9275    fn layout_column_without_localid() {
9276        let md =
9277            "::::layout\n:::column{width=50}\nLeft.\n:::\n:::column{width=50}\nRight.\n:::\n::::";
9278        let doc = markdown_to_adf(md).unwrap();
9279        let cols = doc.content[0].content.as_ref().unwrap();
9280        assert!(
9281            cols[0].attrs.as_ref().unwrap().get("localId").is_none(),
9282            "column without localId should not gain one"
9283        );
9284        let md2 = adf_to_markdown(&doc).unwrap();
9285        assert!(
9286            !md2.contains("localId"),
9287            "no localId should appear in output: {md2}"
9288        );
9289    }
9290
9291    #[test]
9292    fn layout_column_localid_stripped_when_option_set() {
9293        let adf_json = r#"{
9294            "version": 1,
9295            "type": "doc",
9296            "content": [{
9297                "type": "layoutSection",
9298                "content": [{
9299                    "type": "layoutColumn",
9300                    "attrs": {"width": 50.0, "localId": "aabb112233cc"},
9301                    "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Col"}]}]
9302                }]
9303            }]
9304        }"#;
9305        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9306        let opts = RenderOptions {
9307            strip_local_ids: true,
9308            ..Default::default()
9309        };
9310        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
9311        assert!(!md.contains("localId"), "localId should be stripped: {md}");
9312    }
9313
9314    #[test]
9315    fn layout_column_localid_flush_previous() {
9316        // Columns open without explicit `:::` close → flush-previous path
9317        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nLeft.\n:::column{width=50 localId=ddeeff445566}\nRight.\n:::\n::::";
9318        let doc = markdown_to_adf(md).unwrap();
9319        let cols = doc.content[0].content.as_ref().unwrap();
9320        assert_eq!(
9321            cols[0].attrs.as_ref().unwrap()["localId"],
9322            "aabb112233cc",
9323            "flush-previous column should preserve localId"
9324        );
9325        assert_eq!(
9326            cols[1].attrs.as_ref().unwrap()["localId"],
9327            "ddeeff445566",
9328            "second column localId should be preserved"
9329        );
9330    }
9331
9332    #[test]
9333    fn layout_column_localid_flush_last() {
9334        // Layout with no closing fence → column never explicitly closed → flush-last path
9335        let md = "::::layout\n:::column{width=50 localId=aabb112233cc}\nOnly column.";
9336        let doc = markdown_to_adf(md).unwrap();
9337        let cols = doc.content[0].content.as_ref().unwrap();
9338        assert_eq!(
9339            cols[0].attrs.as_ref().unwrap()["localId"],
9340            "aabb112233cc",
9341            "flush-last column should preserve localId"
9342        );
9343    }
9344
9345    /// Issue #555: `layoutColumn` fractional `width` must round-trip byte-for-byte.
9346    #[test]
9347    fn issue_555_layout_column_fractional_width_roundtrip() {
9348        let adf_json = r#"{
9349            "version": 1,
9350            "type": "doc",
9351            "content": [{
9352                "type": "layoutSection",
9353                "content": [
9354                    {
9355                        "type": "layoutColumn",
9356                        "attrs": {"width": 66.66},
9357                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9358                    },
9359                    {
9360                        "type": "layoutColumn",
9361                        "attrs": {"width": 33.34},
9362                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9363                    }
9364                ]
9365            }]
9366        }"#;
9367        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9368        let md = adf_to_markdown(&doc).unwrap();
9369        assert!(md.contains("width=66.66"), "fractional width in md: {md}");
9370        assert!(md.contains("width=33.34"), "fractional width in md: {md}");
9371        let rt = markdown_to_adf(&md).unwrap();
9372        let cols = rt.content[0].content.as_ref().unwrap();
9373        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 66.66);
9374        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 33.34);
9375    }
9376
9377    /// Issue #555: `layoutColumn` 5/6 widths (`83.33`) round-trip without precision loss.
9378    #[test]
9379    fn issue_555_layout_column_five_sixths_width_roundtrip() {
9380        let adf_json = r#"{
9381            "version": 1,
9382            "type": "doc",
9383            "content": [{
9384                "type": "layoutSection",
9385                "content": [
9386                    {
9387                        "type": "layoutColumn",
9388                        "attrs": {"width": 83.33},
9389                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Wide"}]}]
9390                    },
9391                    {
9392                        "type": "layoutColumn",
9393                        "attrs": {"width": 16.67},
9394                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Narrow"}]}]
9395                    }
9396                ]
9397            }]
9398        }"#;
9399        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9400        let md = adf_to_markdown(&doc).unwrap();
9401        let rt = markdown_to_adf(&md).unwrap();
9402        let cols = rt.content[0].content.as_ref().unwrap();
9403        assert_eq!(cols[0].attrs.as_ref().unwrap()["width"], 83.33);
9404        assert_eq!(cols[1].attrs.as_ref().unwrap()["width"], 16.67);
9405    }
9406
9407    /// Issue #555: `layoutColumn` integer widths must NOT be coerced to floats on round-trip.
9408    #[test]
9409    fn issue_555_layout_column_integer_width_preserved() {
9410        let adf_json = r#"{
9411            "version": 1,
9412            "type": "doc",
9413            "content": [{
9414                "type": "layoutSection",
9415                "content": [
9416                    {
9417                        "type": "layoutColumn",
9418                        "attrs": {"width": 50},
9419                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "A"}]}]
9420                    },
9421                    {
9422                        "type": "layoutColumn",
9423                        "attrs": {"width": 50},
9424                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "B"}]}]
9425                    }
9426                ]
9427            }]
9428        }"#;
9429        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9430        let md = adf_to_markdown(&doc).unwrap();
9431        assert!(
9432            md.contains("width=50") && !md.contains("width=50."),
9433            "integer width should render without decimal: {md}"
9434        );
9435        let rt = markdown_to_adf(&md).unwrap();
9436        let cols = rt.content[0].content.as_ref().unwrap();
9437        let w0 = &cols[0].attrs.as_ref().unwrap()["width"];
9438        assert!(
9439            w0.is_i64() || w0.is_u64(),
9440            "width should remain a JSON integer, got: {w0}"
9441        );
9442        assert_eq!(w0.as_i64(), Some(50));
9443    }
9444
9445    /// Issue #555: `mediaSingle` integer `width` must NOT be coerced to a float on round-trip.
9446    #[test]
9447    fn issue_555_media_single_integer_width_preserved() {
9448        let adf_json = r#"{
9449            "version": 1,
9450            "type": "doc",
9451            "content": [{
9452                "type": "mediaSingle",
9453                "attrs": {"layout": "center", "width": 800},
9454                "content": [
9455                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9456                ]
9457            }]
9458        }"#;
9459        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9460        let md = adf_to_markdown(&doc).unwrap();
9461        assert!(
9462            md.contains("width=800") && !md.contains("width=800."),
9463            "integer width should render without decimal: {md}"
9464        );
9465        let rt = markdown_to_adf(&md).unwrap();
9466        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9467        let w = &ms_attrs["width"];
9468        assert!(
9469            w.is_i64() || w.is_u64(),
9470            "mediaSingle width should remain JSON integer, got: {w}"
9471        );
9472        assert_eq!(w.as_i64(), Some(800));
9473    }
9474
9475    /// Issue #555 (follow-up): fractional `mediaSingle` width (e.g. `66.5`, a
9476    /// percentage-based size common in Jira layouts) must survive `from-adf`
9477    /// instead of being silently dropped.
9478    #[test]
9479    fn issue_555_media_single_fractional_width_preserved() {
9480        let adf_json = r#"{
9481            "version": 1,
9482            "type": "doc",
9483            "content": [{
9484                "type": "mediaSingle",
9485                "attrs": {"layout": "center", "width": 66.5},
9486                "content": [
9487                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/diagram.png"}}
9488                ]
9489            }]
9490        }"#;
9491        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9492        let md = adf_to_markdown(&doc).unwrap();
9493        assert!(
9494            md.contains("width=66.5"),
9495            "fractional width must appear in JFM: {md}"
9496        );
9497        let rt = markdown_to_adf(&md).unwrap();
9498        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9499        assert_eq!(ms_attrs["width"], 66.5);
9500    }
9501
9502    /// Issue #555: `mediaSingle` float `width` must not be dropped during ADF→JFM→ADF.
9503    #[test]
9504    fn issue_555_media_single_float_width_preserved() {
9505        let adf_json = r#"{
9506            "version": 1,
9507            "type": "doc",
9508            "content": [{
9509                "type": "mediaSingle",
9510                "attrs": {"layout": "center", "width": 800.0},
9511                "content": [
9512                    {"type": "media", "attrs": {"type": "external", "url": "https://example.com/image.png"}}
9513                ]
9514            }]
9515        }"#;
9516        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9517        let md = adf_to_markdown(&doc).unwrap();
9518        assert!(
9519            md.contains("width=800.0"),
9520            "float width should render with decimal: {md}"
9521        );
9522        let rt = markdown_to_adf(&md).unwrap();
9523        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9524        let w = &ms_attrs["width"];
9525        assert!(
9526            w.is_f64(),
9527            "mediaSingle float width should stay a JSON float, got: {w}"
9528        );
9529        assert_eq!(w.as_f64(), Some(800.0));
9530    }
9531
9532    /// Issue #555: `mediaSingle` with `layout=wide` and integer width must round-trip.
9533    #[test]
9534    fn issue_555_media_single_wide_layout_integer_width_roundtrip() {
9535        let adf_json = r#"{
9536            "version": 1,
9537            "type": "doc",
9538            "content": [{
9539                "type": "mediaSingle",
9540                "attrs": {"layout": "wide", "width": 1420},
9541                "content": [
9542                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9543                ]
9544            }]
9545        }"#;
9546        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9547        let md = adf_to_markdown(&doc).unwrap();
9548        let rt = markdown_to_adf(&md).unwrap();
9549        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9550        assert_eq!(ms_attrs["layout"], "wide");
9551        let w = &ms_attrs["width"];
9552        assert!(
9553            w.is_i64() || w.is_u64(),
9554            "mediaSingle width should remain JSON integer, got: {w}"
9555        );
9556        assert_eq!(w.as_i64(), Some(1420));
9557    }
9558
9559    /// Issue #555: Confluence file-attachment `mediaSingle` with integer `mediaWidth`
9560    /// must round-trip without float coercion.
9561    #[test]
9562    fn issue_555_file_media_single_integer_width_preserved() {
9563        let adf_json = r#"{
9564            "version": 1,
9565            "type": "doc",
9566            "content": [{
9567                "type": "mediaSingle",
9568                "attrs": {"layout": "wide", "width": 1420},
9569                "content": [
9570                    {"type": "media", "attrs": {"id": "abc-123", "type": "file", "collection": "col-1", "width": 1200, "height": 800}}
9571                ]
9572            }]
9573        }"#;
9574        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9575        let md = adf_to_markdown(&doc).unwrap();
9576        let rt = markdown_to_adf(&md).unwrap();
9577        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9578        let ms_w = &ms_attrs["width"];
9579        assert!(ms_w.is_i64() || ms_w.is_u64(), "ms width: {ms_w}");
9580        assert_eq!(ms_w.as_i64(), Some(1420));
9581        let media = &rt.content[0].content.as_ref().unwrap()[0];
9582        let media_attrs = media.attrs.as_ref().unwrap();
9583        let mw = &media_attrs["width"];
9584        assert!(mw.is_i64() || mw.is_u64(), "media width: {mw}");
9585        assert_eq!(mw.as_i64(), Some(1200));
9586        let mh = &media_attrs["height"];
9587        assert!(mh.is_i64() || mh.is_u64(), "media height: {mh}");
9588        assert_eq!(mh.as_i64(), Some(800));
9589    }
9590
9591    /// Issue #555: `fmt_numeric_attr` preserves the original integer/float JSON type.
9592    #[test]
9593    fn issue_555_fmt_numeric_attr_preserves_type() {
9594        assert_eq!(
9595            fmt_numeric_attr(&serde_json::json!(50)).as_deref(),
9596            Some("50")
9597        );
9598        assert_eq!(
9599            fmt_numeric_attr(&serde_json::json!(50.0)).as_deref(),
9600            Some("50.0")
9601        );
9602        assert_eq!(
9603            fmt_numeric_attr(&serde_json::json!(66.66)).as_deref(),
9604            Some("66.66")
9605        );
9606        assert_eq!(
9607            fmt_numeric_attr(&serde_json::json!(-5)).as_deref(),
9608            Some("-5")
9609        );
9610        assert_eq!(fmt_numeric_attr(&serde_json::json!("not a number")), None);
9611        // u64 values above i64::MAX exercise the u64-only branch.
9612        let big = serde_json::Value::Number(serde_json::Number::from(u64::MAX));
9613        assert_eq!(
9614            fmt_numeric_attr(&big).as_deref(),
9615            Some("18446744073709551615")
9616        );
9617        // Null is not a number.
9618        assert_eq!(fmt_numeric_attr(&serde_json::Value::Null), None);
9619    }
9620
9621    /// Issue #555: `parse_numeric_attr` distinguishes integer vs float strings.
9622    #[test]
9623    fn issue_555_parse_numeric_attr_detects_type() {
9624        let v = parse_numeric_attr("800").unwrap();
9625        assert!(v.is_i64() || v.is_u64(), "'800' should parse as integer");
9626        assert_eq!(v.as_i64(), Some(800));
9627
9628        let v = parse_numeric_attr("800.0").unwrap();
9629        assert!(v.is_f64(), "'800.0' should parse as float");
9630        assert_eq!(v.as_f64(), Some(800.0));
9631
9632        let v = parse_numeric_attr("66.66").unwrap();
9633        assert!(v.is_f64());
9634        assert_eq!(v.as_f64(), Some(66.66));
9635
9636        // Scientific notation is treated as float (matches JSON semantics).
9637        let v = parse_numeric_attr("1e2").unwrap();
9638        assert!(v.is_f64());
9639        assert_eq!(v.as_f64(), Some(100.0));
9640        let v = parse_numeric_attr("1E2").unwrap();
9641        assert!(v.is_f64());
9642        assert_eq!(v.as_f64(), Some(100.0));
9643
9644        // Negative integer.
9645        let v = parse_numeric_attr("-42").unwrap();
9646        assert!(v.is_i64());
9647        assert_eq!(v.as_i64(), Some(-42));
9648
9649        assert!(parse_numeric_attr("not-a-number").is_none());
9650        assert!(parse_numeric_attr("").is_none());
9651        assert!(parse_numeric_attr("1.2.3").is_none());
9652    }
9653
9654    /// Issue #555: fractional `mediaSingle` width with non-default `layout=wide`
9655    /// must preserve both the layout and the fractional width through round-trip.
9656    #[test]
9657    fn issue_555_media_single_wide_layout_fractional_width_roundtrip() {
9658        let adf_json = r#"{
9659            "version": 1,
9660            "type": "doc",
9661            "content": [{
9662                "type": "mediaSingle",
9663                "attrs": {"layout": "wide", "width": 83.33},
9664                "content": [
9665                    {"type": "media", "attrs": {"type": "external", "url": "https://ex.com/x.png"}}
9666                ]
9667            }]
9668        }"#;
9669        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9670        let md = adf_to_markdown(&doc).unwrap();
9671        assert!(md.contains("layout=wide"), "layout must appear in md: {md}");
9672        assert!(md.contains("width=83.33"), "width must appear in md: {md}");
9673        let rt = markdown_to_adf(&md).unwrap();
9674        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9675        assert_eq!(ms_attrs["layout"], "wide");
9676        assert_eq!(ms_attrs["width"], 83.33);
9677    }
9678
9679    /// Issue #555: fractional `mediaWidth` on a Confluence file-attachment
9680    /// `mediaSingle` must round-trip (exercises the file-branch `mediaWidth`
9681    /// render path, which previously used `as_u64` and silently dropped floats).
9682    #[test]
9683    fn issue_555_file_media_single_fractional_media_width_preserved() {
9684        let adf_json = r#"{
9685            "version": 1,
9686            "type": "doc",
9687            "content": [{
9688                "type": "mediaSingle",
9689                "attrs": {"layout": "wide", "width": 66.5},
9690                "content": [
9691                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c"}}
9692                ]
9693            }]
9694        }"#;
9695        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9696        let md = adf_to_markdown(&doc).unwrap();
9697        assert!(md.contains("mediaWidth=66.5"), "mediaWidth in md: {md}");
9698        let rt = markdown_to_adf(&md).unwrap();
9699        let ms_attrs = rt.content[0].attrs.as_ref().unwrap();
9700        assert_eq!(ms_attrs["width"], 66.5);
9701    }
9702
9703    /// Issue #555: fractional inner `media` width/height on a file attachment
9704    /// must round-trip (exercises the file-branch inner `width`/`height` render
9705    /// path, which previously used `as_u64` and silently dropped floats).
9706    #[test]
9707    fn issue_555_file_media_fractional_inner_dimensions_preserved() {
9708        let adf_json = r#"{
9709            "version": 1,
9710            "type": "doc",
9711            "content": [{
9712                "type": "mediaSingle",
9713                "attrs": {"layout": "center"},
9714                "content": [
9715                    {"type": "media", "attrs": {"id": "abc", "type": "file", "collection": "c", "width": 1200.5, "height": 800.25}}
9716                ]
9717            }]
9718        }"#;
9719        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9720        let md = adf_to_markdown(&doc).unwrap();
9721        assert!(md.contains("width=1200.5"), "width in md: {md}");
9722        assert!(md.contains("height=800.25"), "height in md: {md}");
9723        let rt = markdown_to_adf(&md).unwrap();
9724        let media = &rt.content[0].content.as_ref().unwrap()[0];
9725        let attrs = media.attrs.as_ref().unwrap();
9726        assert_eq!(attrs["width"], 1200.5);
9727        assert_eq!(attrs["height"], 800.25);
9728    }
9729
9730    #[test]
9731    fn decisions_list() {
9732        let md = ":::decisions\n- <> Use PostgreSQL\n- <> REST API\n:::";
9733        let doc = markdown_to_adf(md).unwrap();
9734        assert_eq!(doc.content[0].node_type, "decisionList");
9735        let items = doc.content[0].content.as_ref().unwrap();
9736        assert_eq!(items.len(), 2);
9737        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DECIDED");
9738    }
9739
9740    // decisionItem is inline-only per ADF schema — its content must be
9741    // text/inline nodes, not a paragraph wrapper (issue #753).
9742    #[test]
9743    fn decision_item_content_is_inline_not_paragraph() {
9744        let md = ":::decisions\n- <> Use Rust\n:::";
9745        let doc = markdown_to_adf(md).unwrap();
9746        let items = doc.content[0].content.as_ref().unwrap();
9747        let first_child = &items[0].content.as_ref().unwrap()[0];
9748        assert_eq!(
9749            first_child.node_type, "text",
9750            "decisionItem must contain inline nodes directly, not a paragraph wrapper"
9751        );
9752        assert_eq!(first_child.text.as_deref(), Some("Use Rust"));
9753    }
9754
9755    #[test]
9756    fn adf_decisions_to_markdown() {
9757        let doc = AdfDocument {
9758            version: 1,
9759            doc_type: "doc".to_string(),
9760            content: vec![AdfNode::decision_list(vec![AdfNode::decision_item(
9761                "DECIDED",
9762                vec![AdfNode::paragraph(vec![AdfNode::text("Use PostgreSQL")])],
9763            )])],
9764        };
9765        let md = adf_to_markdown(&doc).unwrap();
9766        assert!(md.contains(":::decisions"));
9767        assert!(md.contains("- <> Use PostgreSQL"));
9768    }
9769
9770    #[test]
9771    fn bodied_extension_container() {
9772        let md = ":::extension{type=com.forge key=my-macro}\nContent.\n:::";
9773        let doc = markdown_to_adf(md).unwrap();
9774        assert_eq!(doc.content[0].node_type, "bodiedExtension");
9775        assert_eq!(
9776            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9777            "com.forge"
9778        );
9779    }
9780
9781    #[test]
9782    fn adf_bodied_extension_to_markdown() {
9783        let doc = AdfDocument {
9784            version: 1,
9785            doc_type: "doc".to_string(),
9786            content: vec![AdfNode::bodied_extension(
9787                "com.forge",
9788                "my-macro",
9789                vec![AdfNode::paragraph(vec![AdfNode::text("Content.")])],
9790            )],
9791        };
9792        let md = adf_to_markdown(&doc).unwrap();
9793        assert!(md.contains(":::extension{type=com.forge key=my-macro}"));
9794        assert!(md.contains("Content."));
9795    }
9796
9797    // ── Leaf block directive tests (Tier 3) ──────────────────────────
9798
9799    #[test]
9800    fn leaf_block_card() {
9801        let doc = markdown_to_adf("::card[https://example.com/browse/PROJ-123]").unwrap();
9802        assert_eq!(doc.content[0].node_type, "blockCard");
9803        assert_eq!(
9804            doc.content[0].attrs.as_ref().unwrap()["url"],
9805            "https://example.com/browse/PROJ-123"
9806        );
9807    }
9808
9809    #[test]
9810    fn adf_block_card_to_markdown() {
9811        let doc = AdfDocument {
9812            version: 1,
9813            doc_type: "doc".to_string(),
9814            content: vec![AdfNode::block_card("https://example.com/browse/PROJ-123")],
9815        };
9816        let md = adf_to_markdown(&doc).unwrap();
9817        assert!(md.contains("::card[https://example.com/browse/PROJ-123]"));
9818    }
9819
9820    #[test]
9821    fn round_trip_block_card() {
9822        let md = "::card[https://example.com/browse/PROJ-123]\n";
9823        let doc = markdown_to_adf(md).unwrap();
9824        let result = adf_to_markdown(&doc).unwrap();
9825        assert!(result.contains("::card[https://example.com/browse/PROJ-123]"));
9826    }
9827
9828    #[test]
9829    fn leaf_embed_card() {
9830        let doc =
9831            markdown_to_adf("::embed[https://figma.com/file/abc]{layout=wide width=80}").unwrap();
9832        assert_eq!(doc.content[0].node_type, "embedCard");
9833        let attrs = doc.content[0].attrs.as_ref().unwrap();
9834        assert_eq!(attrs["url"], "https://figma.com/file/abc");
9835        assert_eq!(attrs["layout"], "wide");
9836        assert_eq!(attrs["width"], 80.0);
9837    }
9838
9839    #[test]
9840    fn leaf_embed_card_with_original_height() {
9841        let doc = markdown_to_adf(
9842            "::embed[https://example.com]{layout=center originalHeight=732 width=100}",
9843        )
9844        .unwrap();
9845        assert_eq!(doc.content[0].node_type, "embedCard");
9846        let attrs = doc.content[0].attrs.as_ref().unwrap();
9847        assert_eq!(attrs["url"], "https://example.com");
9848        assert_eq!(attrs["layout"], "center");
9849        assert_eq!(attrs["originalHeight"], 732.0);
9850        assert_eq!(attrs["width"], 100.0);
9851    }
9852
9853    #[test]
9854    fn adf_embed_card_to_markdown() {
9855        let doc = AdfDocument {
9856            version: 1,
9857            doc_type: "doc".to_string(),
9858            content: vec![AdfNode::embed_card(
9859                "https://figma.com/file/abc",
9860                Some("wide"),
9861                None,
9862                Some(80.0),
9863            )],
9864        };
9865        let md = adf_to_markdown(&doc).unwrap();
9866        assert!(md.contains("::embed[https://figma.com/file/abc]{layout=wide width=80}"));
9867    }
9868
9869    #[test]
9870    fn adf_embed_card_original_height_to_markdown() {
9871        let doc = AdfDocument {
9872            version: 1,
9873            doc_type: "doc".to_string(),
9874            content: vec![AdfNode::embed_card(
9875                "https://example.com",
9876                Some("center"),
9877                Some(732.0),
9878                Some(100.0),
9879            )],
9880        };
9881        let md = adf_to_markdown(&doc).unwrap();
9882        assert!(
9883            md.contains("::embed[https://example.com]{layout=center originalHeight=732 width=100}"),
9884            "expected originalHeight and width in md: {md}"
9885        );
9886    }
9887
9888    #[test]
9889    fn embed_card_roundtrip_with_all_attrs() {
9890        let adf_json = r#"{"version":1,"type":"doc","content":[{
9891            "type":"embedCard",
9892            "attrs":{"layout":"center","originalHeight":732.0,"url":"https://example.com","width":100.0}
9893        }]}"#;
9894        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9895        let md = adf_to_markdown(&doc).unwrap();
9896        assert!(
9897            md.contains("originalHeight=732"),
9898            "originalHeight missing from md: {md}"
9899        );
9900        assert!(md.contains("width=100"), "width missing from md: {md}");
9901        let rt = markdown_to_adf(&md).unwrap();
9902        let attrs = rt.content[0].attrs.as_ref().unwrap();
9903        assert_eq!(attrs["originalHeight"], 732.0);
9904        assert_eq!(attrs["width"], 100.0);
9905        assert_eq!(attrs["layout"], "center");
9906        assert_eq!(attrs["url"], "https://example.com");
9907    }
9908
9909    #[test]
9910    fn embed_card_fractional_dimensions() {
9911        let doc = AdfDocument {
9912            version: 1,
9913            doc_type: "doc".to_string(),
9914            content: vec![AdfNode::embed_card(
9915                "https://example.com",
9916                Some("center"),
9917                Some(732.5),
9918                Some(99.9),
9919            )],
9920        };
9921        let md = adf_to_markdown(&doc).unwrap();
9922        assert!(
9923            md.contains("originalHeight=732.5"),
9924            "fractional originalHeight missing: {md}"
9925        );
9926        assert!(md.contains("width=99.9"), "fractional width missing: {md}");
9927        let rt = markdown_to_adf(&md).unwrap();
9928        let attrs = rt.content[0].attrs.as_ref().unwrap();
9929        assert_eq!(attrs["originalHeight"], 732.5);
9930        assert_eq!(attrs["width"], 99.9);
9931    }
9932
9933    #[test]
9934    fn embed_card_integer_width_in_json() {
9935        // JSON integer (not float) should also be extracted via as_f64()
9936        let adf_json = r#"{"version":1,"type":"doc","content":[{
9937            "type":"embedCard",
9938            "attrs":{"url":"https://example.com","width":100}
9939        }]}"#;
9940        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9941        let md = adf_to_markdown(&doc).unwrap();
9942        assert!(
9943            md.contains("width=100"),
9944            "integer width missing from md: {md}"
9945        );
9946        let rt = markdown_to_adf(&md).unwrap();
9947        assert_eq!(rt.content[0].attrs.as_ref().unwrap()["width"], 100.0);
9948    }
9949
9950    #[test]
9951    fn embed_card_only_original_height() {
9952        // originalHeight without width
9953        let adf_json = r#"{"version":1,"type":"doc","content":[{
9954            "type":"embedCard",
9955            "attrs":{"url":"https://example.com","originalHeight":500.0}
9956        }]}"#;
9957        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
9958        let md = adf_to_markdown(&doc).unwrap();
9959        assert!(
9960            md.contains("originalHeight=500"),
9961            "originalHeight missing: {md}"
9962        );
9963        assert!(!md.contains("width="), "width should not appear: {md}");
9964        let rt = markdown_to_adf(&md).unwrap();
9965        let attrs = rt.content[0].attrs.as_ref().unwrap();
9966        assert_eq!(attrs["originalHeight"], 500.0);
9967        assert!(attrs.get("width").is_none());
9968    }
9969
9970    #[test]
9971    fn leaf_void_extension() {
9972        let doc = markdown_to_adf("::extension{type=com.atlassian.macro key=jira-chart}").unwrap();
9973        assert_eq!(doc.content[0].node_type, "extension");
9974        assert_eq!(
9975            doc.content[0].attrs.as_ref().unwrap()["extensionType"],
9976            "com.atlassian.macro"
9977        );
9978        assert_eq!(
9979            doc.content[0].attrs.as_ref().unwrap()["extensionKey"],
9980            "jira-chart"
9981        );
9982    }
9983
9984    #[test]
9985    fn adf_void_extension_to_markdown() {
9986        let doc = AdfDocument {
9987            version: 1,
9988            doc_type: "doc".to_string(),
9989            content: vec![AdfNode::extension(
9990                "com.atlassian.macro",
9991                "jira-chart",
9992                None,
9993            )],
9994        };
9995        let md = adf_to_markdown(&doc).unwrap();
9996        assert!(md.contains("::extension{type=com.atlassian.macro key=jira-chart}"));
9997    }
9998
9999    // ── Bare URL autolink tests ──────────────────────────────────────
10000
10001    #[test]
10002    fn bare_url_autolink() {
10003        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10004        let content = doc.content[0].content.as_ref().unwrap();
10005        assert_eq!(content[0].text.as_deref(), Some("Visit "));
10006        assert_eq!(content[1].node_type, "inlineCard");
10007        assert_eq!(
10008            content[1].attrs.as_ref().unwrap()["url"],
10009            "https://example.com"
10010        );
10011        assert_eq!(content[2].text.as_deref(), Some(" today"));
10012    }
10013
10014    #[test]
10015    fn bare_url_strips_trailing_punctuation() {
10016        let doc = markdown_to_adf("See https://example.com.").unwrap();
10017        let content = doc.content[0].content.as_ref().unwrap();
10018        assert_eq!(
10019            content[1].attrs.as_ref().unwrap()["url"],
10020            "https://example.com"
10021        );
10022    }
10023
10024    #[test]
10025    fn bare_url_round_trip() {
10026        let doc = markdown_to_adf("Visit https://example.com/path today").unwrap();
10027        let md = adf_to_markdown(&doc).unwrap();
10028        assert!(md.contains(":card[https://example.com/path]"));
10029    }
10030
10031    // ── Issue #475: plain-text URL must not become inlineCard ─────────
10032
10033    #[test]
10034    fn plain_text_url_round_trips_as_text() {
10035        // A text node whose content is a bare URL (no link mark) must
10036        // survive ADF→JFM→ADF as a text node, not an inlineCard.
10037        let adf_json = r#"{
10038            "version": 1,
10039            "type": "doc",
10040            "content": [{
10041                "type": "paragraph",
10042                "content": [
10043                    {"type": "text", "text": "https://example.com/some/path/to/resource"}
10044                ]
10045            }]
10046        }"#;
10047        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10048        let jfm = adf_to_markdown(&adf).unwrap();
10049        let roundtripped = markdown_to_adf(&jfm).unwrap();
10050        let content = roundtripped.content[0].content.as_ref().unwrap();
10051        assert_eq!(content.len(), 1, "should be a single node");
10052        assert_eq!(content[0].node_type, "text");
10053        assert_eq!(
10054            content[0].text.as_deref(),
10055            Some("https://example.com/some/path/to/resource")
10056        );
10057    }
10058
10059    #[test]
10060    fn url_text_with_link_mark_round_trips_as_text_node() {
10061        // Issue #523: A text node whose content is a URL with a link mark
10062        // (href differs by trailing slash) must round-trip as text+link,
10063        // not become an inlineCard.
10064        let adf_json = r#"{
10065            "version": 1,
10066            "type": "doc",
10067            "content": [{
10068                "type": "paragraph",
10069                "content": [{
10070                    "type": "text",
10071                    "text": "https://octopz.example.com",
10072                    "marks": [{"type": "link", "attrs": {"href": "https://octopz.example.com/"}}]
10073                }]
10074            }]
10075        }"#;
10076        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10077        let jfm = adf_to_markdown(&adf).unwrap();
10078        let roundtripped = markdown_to_adf(&jfm).unwrap();
10079        let content = roundtripped.content[0].content.as_ref().unwrap();
10080        assert_eq!(content.len(), 1, "should be a single node");
10081        assert_eq!(content[0].node_type, "text", "must be text, not inlineCard");
10082        assert_eq!(
10083            content[0].text.as_deref(),
10084            Some("https://octopz.example.com")
10085        );
10086        let mark = &content[0].marks.as_ref().unwrap()[0];
10087        assert_eq!(mark.mark_type, "link");
10088        assert_eq!(
10089            mark.attrs.as_ref().unwrap()["href"],
10090            "https://octopz.example.com/"
10091        );
10092    }
10093
10094    #[test]
10095    fn url_text_with_exact_link_mark_round_trips() {
10096        // Variant: text and href are identical (no trailing slash difference).
10097        let adf_json = r#"{
10098            "version": 1,
10099            "type": "doc",
10100            "content": [{
10101                "type": "paragraph",
10102                "content": [{
10103                    "type": "text",
10104                    "text": "https://example.com/path",
10105                    "marks": [{"type": "link", "attrs": {"href": "https://example.com/path"}}]
10106                }]
10107            }]
10108        }"#;
10109        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10110        let jfm = adf_to_markdown(&adf).unwrap();
10111        let roundtripped = markdown_to_adf(&jfm).unwrap();
10112        let content = roundtripped.content[0].content.as_ref().unwrap();
10113        assert_eq!(content.len(), 1, "should be a single node");
10114        assert_eq!(content[0].node_type, "text");
10115        assert_eq!(content[0].text.as_deref(), Some("https://example.com/path"));
10116        let mark = &content[0].marks.as_ref().unwrap()[0];
10117        assert_eq!(mark.mark_type, "link");
10118    }
10119
10120    #[test]
10121    fn plain_text_url_amid_text_round_trips() {
10122        // URL embedded in surrounding text, without link mark.
10123        let adf_json = r#"{
10124            "version": 1,
10125            "type": "doc",
10126            "content": [{
10127                "type": "paragraph",
10128                "content": [
10129                    {"type": "text", "text": "see https://example.com for info"}
10130                ]
10131            }]
10132        }"#;
10133        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10134        let jfm = adf_to_markdown(&adf).unwrap();
10135        let roundtripped = markdown_to_adf(&jfm).unwrap();
10136        let content = roundtripped.content[0].content.as_ref().unwrap();
10137        assert_eq!(content.len(), 1);
10138        assert_eq!(content[0].node_type, "text");
10139        assert_eq!(
10140            content[0].text.as_deref(),
10141            Some("see https://example.com for info")
10142        );
10143    }
10144
10145    #[test]
10146    fn plain_text_multiple_urls_round_trips() {
10147        let adf_json = r#"{
10148            "version": 1,
10149            "type": "doc",
10150            "content": [{
10151                "type": "paragraph",
10152                "content": [
10153                    {"type": "text", "text": "http://a.com and https://b.com"}
10154                ]
10155            }]
10156        }"#;
10157        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10158        let jfm = adf_to_markdown(&adf).unwrap();
10159        let roundtripped = markdown_to_adf(&jfm).unwrap();
10160        let content = roundtripped.content[0].content.as_ref().unwrap();
10161        assert_eq!(content.len(), 1);
10162        assert_eq!(content[0].node_type, "text");
10163        assert_eq!(
10164            content[0].text.as_deref(),
10165            Some("http://a.com and https://b.com")
10166        );
10167    }
10168
10169    #[test]
10170    fn plain_text_http_prefix_no_url_unchanged() {
10171        // "http" without "://" should not be escaped or altered.
10172        let adf_json = r#"{
10173            "version": 1,
10174            "type": "doc",
10175            "content": [{
10176                "type": "paragraph",
10177                "content": [
10178                    {"type": "text", "text": "the http header is important"}
10179                ]
10180            }]
10181        }"#;
10182        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10183        let jfm = adf_to_markdown(&adf).unwrap();
10184        let roundtripped = markdown_to_adf(&jfm).unwrap();
10185        let content = roundtripped.content[0].content.as_ref().unwrap();
10186        assert_eq!(
10187            content[0].text.as_deref(),
10188            Some("the http header is important")
10189        );
10190    }
10191
10192    #[test]
10193    fn linked_url_text_round_trips() {
10194        // A text node that is exactly a URL with a link mark pointing to the
10195        // same URL must round-trip as a single text node with a link mark
10196        // (no inlineCard, no lost/split content).
10197        let adf_json = r#"{
10198            "version": 1,
10199            "type": "doc",
10200            "content": [{
10201                "type": "paragraph",
10202                "content": [{
10203                    "type": "text",
10204                    "text": "https://example.com",
10205                    "marks": [{"type": "link", "attrs": {"href": "https://example.com"}}]
10206                }]
10207            }]
10208        }"#;
10209        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10210        let jfm = adf_to_markdown(&adf).unwrap();
10211        let roundtripped = markdown_to_adf(&jfm).unwrap();
10212        let content = roundtripped.content[0].content.as_ref().unwrap();
10213        assert_eq!(content.len(), 1);
10214        assert_eq!(content[0].node_type, "text");
10215        assert_eq!(content[0].text.as_deref(), Some("https://example.com"));
10216        let mark = &content[0].marks.as_ref().unwrap()[0];
10217        assert_eq!(mark.mark_type, "link");
10218        assert_eq!(mark.attrs.as_ref().unwrap()["href"], "https://example.com");
10219    }
10220
10221    // ── Issue #493: bracket-link ambiguity ─────────────────────────────
10222
10223    #[test]
10224    fn escape_link_brackets_unit() {
10225        assert_eq!(escape_link_brackets("hello"), "hello");
10226        assert_eq!(escape_link_brackets("["), "\\[");
10227        assert_eq!(escape_link_brackets("]"), "\\]");
10228        assert_eq!(escape_link_brackets("[PROJ-456]"), "\\[PROJ-456\\]");
10229        assert_eq!(escape_link_brackets("a[b]c"), "a\\[b\\]c");
10230    }
10231
10232    #[test]
10233    fn bracket_text_with_link_mark_escapes_brackets() {
10234        // A text node whose content is "[" with a link mark should escape
10235        // the bracket so it does not create ambiguous markdown link syntax.
10236        let doc = AdfDocument {
10237            version: 1,
10238            doc_type: "doc".to_string(),
10239            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10240                "[",
10241                vec![AdfMark::link("https://example.com")],
10242            )])],
10243        };
10244        let md = adf_to_markdown(&doc).unwrap();
10245        assert_eq!(md.trim(), "[\\[](https://example.com)");
10246    }
10247
10248    #[test]
10249    fn bracket_text_with_link_mark_round_trips() {
10250        // Issue #493 reproducer: adjacent text nodes sharing a link mark
10251        // where the first node's content is "[".
10252        let adf_json = r#"{
10253            "type": "doc",
10254            "version": 1,
10255            "content": [{
10256                "type": "paragraph",
10257                "content": [
10258                    {
10259                        "type": "text",
10260                        "text": "[",
10261                        "marks": [{"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}]
10262                    },
10263                    {
10264                        "type": "text",
10265                        "text": "PROJ-456] Fix the auth bug",
10266                        "marks": [
10267                            {"type": "underline"},
10268                            {"type": "link", "attrs": {"href": "https://example.com/ticket/123"}}
10269                        ]
10270                    }
10271                ]
10272            }]
10273        }"#;
10274        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10275        let jfm = adf_to_markdown(&adf).unwrap();
10276
10277        // The markdown should contain escaped brackets inside the link
10278        assert!(jfm.contains("\\["), "opening bracket should be escaped");
10279
10280        // Round-trip: both text nodes must survive with link marks
10281        let rt = markdown_to_adf(&jfm).unwrap();
10282        let content = rt.content[0].content.as_ref().unwrap();
10283
10284        // All text nodes that were part of the link must still carry a link mark
10285        let link_nodes: Vec<_> = content
10286            .iter()
10287            .filter(|n| {
10288                n.marks
10289                    .as_ref()
10290                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
10291            })
10292            .collect();
10293        assert!(
10294            !link_nodes.is_empty(),
10295            "link mark must be preserved on round-trip"
10296        );
10297
10298        // The combined text across all nodes should contain the original content
10299        let all_text: String = content.iter().filter_map(|n| n.text.as_deref()).collect();
10300        assert!(
10301            all_text.contains('['),
10302            "literal '[' must survive round-trip"
10303        );
10304        assert!(
10305            all_text.contains("PROJ-456]"),
10306            "continuation text must survive round-trip"
10307        );
10308    }
10309
10310    #[test]
10311    fn closing_bracket_in_link_text_round_trips() {
10312        // A text node containing "]" inside a link should be escaped and
10313        // survive round-trip without breaking the link syntax.
10314        let doc = AdfDocument {
10315            version: 1,
10316            doc_type: "doc".to_string(),
10317            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10318                "item]",
10319                vec![AdfMark::link("https://example.com")],
10320            )])],
10321        };
10322        let md = adf_to_markdown(&doc).unwrap();
10323        assert_eq!(md.trim(), "[item\\]](https://example.com)");
10324
10325        let rt = markdown_to_adf(&md).unwrap();
10326        let content = rt.content[0].content.as_ref().unwrap();
10327        assert_eq!(content[0].text.as_deref(), Some("item]"));
10328        assert!(content[0]
10329            .marks
10330            .as_ref()
10331            .unwrap()
10332            .iter()
10333            .any(|m| m.mark_type == "link"));
10334    }
10335
10336    #[test]
10337    fn brackets_in_link_text_round_trip() {
10338        // Text containing both [ and ] inside a link should round-trip.
10339        let doc = AdfDocument {
10340            version: 1,
10341            doc_type: "doc".to_string(),
10342            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10343                "[PROJ-123]",
10344                vec![AdfMark::link("https://example.com")],
10345            )])],
10346        };
10347        let md = adf_to_markdown(&doc).unwrap();
10348        assert_eq!(md.trim(), "[\\[PROJ-123\\]](https://example.com)");
10349
10350        let rt = markdown_to_adf(&md).unwrap();
10351        let content = rt.content[0].content.as_ref().unwrap();
10352        assert_eq!(content[0].text.as_deref(), Some("[PROJ-123]"));
10353        assert!(content[0]
10354            .marks
10355            .as_ref()
10356            .unwrap()
10357            .iter()
10358            .any(|m| m.mark_type == "link"));
10359    }
10360
10361    #[test]
10362    fn plain_text_brackets_not_escaped() {
10363        // Brackets in plain text (no link mark) must NOT be escaped.
10364        let doc = AdfDocument {
10365            version: 1,
10366            doc_type: "doc".to_string(),
10367            content: vec![AdfNode::paragraph(vec![AdfNode::text(
10368                "see [PROJ-123] for details",
10369            )])],
10370        };
10371        let md = adf_to_markdown(&doc).unwrap();
10372        assert_eq!(md.trim(), "see [PROJ-123] for details");
10373    }
10374
10375    #[test]
10376    fn link_with_no_brackets_unchanged() {
10377        // A normal link with no brackets in the text should be unaffected.
10378        let doc = AdfDocument {
10379            version: 1,
10380            doc_type: "doc".to_string(),
10381            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10382                "click here",
10383                vec![AdfMark::link("https://example.com")],
10384            )])],
10385        };
10386        let md = adf_to_markdown(&doc).unwrap();
10387        assert_eq!(md.trim(), "[click here](https://example.com)");
10388    }
10389
10390    // ── Issue #551: URL brackets in link-marked text round-trip ────────
10391
10392    #[test]
10393    fn url_with_brackets_as_link_text_round_trips() {
10394        // Issue #551: a text node whose content is a URL containing square
10395        // brackets and which carries a link mark must round-trip verbatim.
10396        // Previously the URL-as-link-text fast path preserved `\[` and `\]`
10397        // escapes in the emitted text, corrupting the text content.
10398        let href = "https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10399        let doc = AdfDocument {
10400            version: 1,
10401            doc_type: "doc".to_string(),
10402            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10403                href,
10404                vec![AdfMark::link(href)],
10405            )])],
10406        };
10407        let md = adf_to_markdown(&doc).unwrap();
10408        let rt = markdown_to_adf(&md).unwrap();
10409        let content = rt.content[0].content.as_ref().unwrap();
10410        assert_eq!(content.len(), 1);
10411        assert_eq!(content[0].node_type, "text");
10412        assert_eq!(content[0].text.as_deref(), Some(href));
10413        let mark = &content[0].marks.as_ref().unwrap()[0];
10414        assert_eq!(mark.mark_type, "link");
10415        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10416    }
10417
10418    #[test]
10419    fn url_with_brackets_embedded_in_link_text_round_trips() {
10420        // Issue #551 updated reproducer: a link-marked text node containing
10421        // both prose and an embedded URL with brackets must round-trip
10422        // without the embedded URL being detected as a bare-URL inlineCard
10423        // or the brackets terminating the link syntax early.  This mirrors
10424        // the comment reproducer which uses an ellipsis character between
10425        // the brackets and a distinct href value.
10426        let href = "https://example.com/logs?query=service%20environment%20data&from=100&to=200";
10427        let text =
10428            "See the logs: https://example.com/logs?query=service[\u{2026}]data&from=100&to=200";
10429        let doc = AdfDocument {
10430            version: 1,
10431            doc_type: "doc".to_string(),
10432            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10433                text,
10434                vec![AdfMark::link(href)],
10435            )])],
10436        };
10437        let md = adf_to_markdown(&doc).unwrap();
10438        let rt = markdown_to_adf(&md).unwrap();
10439        let content = rt.content[0].content.as_ref().unwrap();
10440        assert_eq!(content.len(), 1, "content split unexpectedly: {content:?}");
10441        assert_eq!(content[0].node_type, "text");
10442        assert_eq!(content[0].text.as_deref(), Some(text));
10443        let mark = &content[0].marks.as_ref().unwrap()[0];
10444        assert_eq!(mark.mark_type, "link");
10445        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10446    }
10447
10448    #[test]
10449    fn url_with_brackets_plain_text_round_trips() {
10450        // Issue #551 original reproducer: plain text with an embedded URL
10451        // that contains square brackets must round-trip verbatim.
10452        let text =
10453            "See the dashboard: https://example.com/dashboard?filter[0]=active&filter[1]=pending";
10454        let doc = AdfDocument {
10455            version: 1,
10456            doc_type: "doc".to_string(),
10457            content: vec![AdfNode::paragraph(vec![AdfNode::text(text)])],
10458        };
10459        let md = adf_to_markdown(&doc).unwrap();
10460        let rt = markdown_to_adf(&md).unwrap();
10461        let content = rt.content[0].content.as_ref().unwrap();
10462        assert_eq!(content.len(), 1);
10463        assert_eq!(content[0].node_type, "text");
10464        assert_eq!(content[0].text.as_deref(), Some(text));
10465        assert!(content[0].marks.is_none());
10466    }
10467
10468    #[test]
10469    fn url_with_link_mark_embedded_no_brackets_round_trips() {
10470        // Regression guard: embedding a bare URL inside link-marked text
10471        // (no brackets) must not create an inlineCard on round-trip.
10472        let href = "https://example.com/";
10473        let text = "See https://example.com/ for more";
10474        let doc = AdfDocument {
10475            version: 1,
10476            doc_type: "doc".to_string(),
10477            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10478                text,
10479                vec![AdfMark::link(href)],
10480            )])],
10481        };
10482        let md = adf_to_markdown(&doc).unwrap();
10483        let rt = markdown_to_adf(&md).unwrap();
10484        let content = rt.content[0].content.as_ref().unwrap();
10485        assert_eq!(content.len(), 1);
10486        assert_eq!(content[0].node_type, "text");
10487        assert_eq!(content[0].text.as_deref(), Some(text));
10488        let mark = &content[0].marks.as_ref().unwrap()[0];
10489        assert_eq!(mark.mark_type, "link");
10490        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10491    }
10492
10493    #[test]
10494    fn nested_brackets_in_link_text_round_trip() {
10495        // Regression guard: nested brackets in link-marked text must
10496        // round-trip without corrupting the content.
10497        let href = "https://x.com";
10498        let text = "foo [a[b]c] bar";
10499        let doc = AdfDocument {
10500            version: 1,
10501            doc_type: "doc".to_string(),
10502            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10503                text,
10504                vec![AdfMark::link(href)],
10505            )])],
10506        };
10507        let md = adf_to_markdown(&doc).unwrap();
10508        let rt = markdown_to_adf(&md).unwrap();
10509        let content = rt.content[0].content.as_ref().unwrap();
10510        assert_eq!(content.len(), 1);
10511        assert_eq!(content[0].node_type, "text");
10512        assert_eq!(content[0].text.as_deref(), Some(text));
10513    }
10514
10515    #[test]
10516    fn bracket_url_bracket_in_link_text_round_trips() {
10517        // Regression guard: a link-marked text containing brackets on both
10518        // sides of an embedded URL (with brackets of its own) must
10519        // round-trip intact.  This exercises interaction between the
10520        // URL-as-link-text fast path, bare-URL detection, and bracket
10521        // escape handling.
10522        let href = "https://y.com";
10523        let text = "[see https://x.com/a[0]=1 here]";
10524        let doc = AdfDocument {
10525            version: 1,
10526            doc_type: "doc".to_string(),
10527            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10528                text,
10529                vec![AdfMark::link(href)],
10530            )])],
10531        };
10532        let md = adf_to_markdown(&doc).unwrap();
10533        let rt = markdown_to_adf(&md).unwrap();
10534        let content = rt.content[0].content.as_ref().unwrap();
10535        assert_eq!(content.len(), 1);
10536        assert_eq!(content[0].node_type, "text");
10537        assert_eq!(content[0].text.as_deref(), Some(text));
10538        let mark = &content[0].marks.as_ref().unwrap()[0];
10539        assert_eq!(mark.mark_type, "link");
10540        assert_eq!(mark.attrs.as_ref().unwrap()["href"], href);
10541    }
10542
10543    #[test]
10544    fn escape_bare_urls_applied_inside_link_text() {
10545        // White-box: when a text node carries a link mark, bare URLs in the
10546        // text must still be escaped with `\h` so the parser does not
10547        // auto-link them into an inlineCard inside the link.  Without this,
10548        // round-trip of link-marked prose containing an embedded URL
10549        // silently corrupts on re-parse (issue #551).
10550        let doc = AdfDocument {
10551            version: 1,
10552            doc_type: "doc".to_string(),
10553            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
10554                "See https://example.com/",
10555                vec![AdfMark::link("https://target.example.com/")],
10556            )])],
10557        };
10558        let md = adf_to_markdown(&doc).unwrap();
10559        assert!(
10560            md.contains(r"\https://example.com/"),
10561            "bare URL inside link text must be escaped, got: {md}"
10562        );
10563    }
10564
10565    #[test]
10566    fn inline_card_still_round_trips() {
10567        // An actual inlineCard node should still round-trip correctly
10568        // (it uses :card[url] syntax, not bare URL).
10569        let adf_json = r#"{
10570            "version": 1,
10571            "type": "doc",
10572            "content": [{
10573                "type": "paragraph",
10574                "content": [
10575                    {"type": "inlineCard", "attrs": {"url": "https://example.com/page"}}
10576                ]
10577            }]
10578        }"#;
10579        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10580        let jfm = adf_to_markdown(&adf).unwrap();
10581        assert!(jfm.contains(":card[https://example.com/page]"));
10582        let roundtripped = markdown_to_adf(&jfm).unwrap();
10583        let content = roundtripped.content[0].content.as_ref().unwrap();
10584        assert_eq!(content[0].node_type, "inlineCard");
10585        assert_eq!(
10586            content[0].attrs.as_ref().unwrap()["url"],
10587            "https://example.com/page"
10588        );
10589    }
10590
10591    // ── Issue #553: inlineCard round-trip with problematic URLs ───────
10592
10593    #[test]
10594    fn url_safe_in_bracket_content_balanced() {
10595        // Balanced brackets — depth never returns to zero mid-string.
10596        assert!(url_safe_in_bracket_content("https://example.com"));
10597        assert!(url_safe_in_bracket_content("https://example.com/[id]"));
10598        assert!(url_safe_in_bracket_content("a[b[c]d]e"));
10599        assert!(url_safe_in_bracket_content(""));
10600    }
10601
10602    #[test]
10603    fn url_safe_in_bracket_content_unbalanced() {
10604        // A `]` with no prior `[` would close `:card[...]` early.
10605        assert!(!url_safe_in_bracket_content("a]b"));
10606        assert!(!url_safe_in_bracket_content("https://example.com/path]end"));
10607        // Embedded newline breaks inline directive parsing.
10608        assert!(!url_safe_in_bracket_content("a\nb"));
10609    }
10610
10611    #[test]
10612    fn inline_card_url_with_closing_bracket_round_trip() {
10613        // Issue #553 defensive fix: a URL that contains `]` (unbalanced) must
10614        // round-trip without truncation.  The renderer must switch to the
10615        // quoted attribute form `:card[]{url="..."}` so the parser's
10616        // depth-based bracket matcher does not terminate the directive early.
10617        let adf_json = r#"{
10618            "version": 1,
10619            "type": "doc",
10620            "content": [{
10621                "type": "paragraph",
10622                "content": [
10623                    {"type": "text", "text": "See: "},
10624                    {"type": "inlineCard", "attrs": {"url": "https://example.com/path]end/?q=1"}}
10625                ]
10626            }]
10627        }"#;
10628        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10629        let jfm = adf_to_markdown(&adf).unwrap();
10630        assert!(
10631            jfm.contains(r#":card[]{url="https://example.com/path]end/?q=1"}"#),
10632            "expected attr-form for URL with `]`, got: {jfm}"
10633        );
10634        let rt = markdown_to_adf(&jfm).unwrap();
10635        let content = rt.content[0].content.as_ref().unwrap();
10636        assert_eq!(content.len(), 2, "expected 2 inline nodes, got {content:?}");
10637        assert_eq!(content[0].node_type, "text");
10638        assert_eq!(content[0].text.as_deref(), Some("See: "));
10639        assert_eq!(content[1].node_type, "inlineCard");
10640        assert_eq!(
10641            content[1].attrs.as_ref().unwrap()["url"],
10642            "https://example.com/path]end/?q=1"
10643        );
10644    }
10645
10646    #[test]
10647    fn inline_card_url_with_closing_bracket_preserves_local_id() {
10648        // Attr-form `:card[]{url=... localId=...}` must preserve localId too.
10649        let adf_json = r#"{
10650            "version": 1,
10651            "type": "doc",
10652            "content": [{
10653                "type": "paragraph",
10654                "content": [
10655                    {"type": "inlineCard", "attrs": {
10656                        "url": "https://example.com/a]b",
10657                        "localId": "c-77"
10658                    }}
10659                ]
10660            }]
10661        }"#;
10662        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10663        let jfm = adf_to_markdown(&adf).unwrap();
10664        assert!(
10665            jfm.contains(r#"url="https://example.com/a]b""#),
10666            "jfm: {jfm}"
10667        );
10668        assert!(jfm.contains("localId=c-77"), "jfm: {jfm}");
10669        let rt = markdown_to_adf(&jfm).unwrap();
10670        let card = &rt.content[0].content.as_ref().unwrap()[0];
10671        assert_eq!(card.node_type, "inlineCard");
10672        assert_eq!(
10673            card.attrs.as_ref().unwrap()["url"],
10674            "https://example.com/a]b"
10675        );
10676        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-77");
10677    }
10678
10679    #[test]
10680    fn block_card_url_with_closing_bracket_round_trip() {
10681        // Same defensive fix applied to the leaf directive `::card`.
10682        let adf_json = r#"{
10683            "version": 1,
10684            "type": "doc",
10685            "content": [
10686                {"type": "blockCard", "attrs": {"url": "https://example.com/path]end"}}
10687            ]
10688        }"#;
10689        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10690        let jfm = adf_to_markdown(&adf).unwrap();
10691        assert!(
10692            jfm.contains(r#"::card[]{url="https://example.com/path]end"}"#),
10693            "expected attr-form for blockCard with `]`, got: {jfm}"
10694        );
10695        let rt = markdown_to_adf(&jfm).unwrap();
10696        assert_eq!(rt.content[0].node_type, "blockCard");
10697        assert_eq!(
10698            rt.content[0].attrs.as_ref().unwrap()["url"],
10699            "https://example.com/path]end"
10700        );
10701    }
10702
10703    #[test]
10704    fn block_card_attr_form_parses_without_renderer() {
10705        // Directly parsing `::card[]{url="..."}` exercises the attr-URL
10706        // fallback in the leaf-directive dispatcher (covers the `url` lookup
10707        // path independently of the ADF→JFM renderer).
10708        let doc = markdown_to_adf(r#"::card[]{url="https://example.com/a"}"#).unwrap();
10709        assert_eq!(doc.content[0].node_type, "blockCard");
10710        assert_eq!(
10711            doc.content[0].attrs.as_ref().unwrap()["url"],
10712            "https://example.com/a"
10713        );
10714    }
10715
10716    #[test]
10717    fn block_card_attr_form_url_overrides_content() {
10718        // When both bracket-content and `url=` attribute are present on
10719        // `::card`, the attribute wins.  Mirrors the inline-directive
10720        // behaviour and keeps hand-edited JFM forgiving.
10721        let doc =
10722            markdown_to_adf(r#"::card[https://old.example.com]{url="https://new.example.com"}"#)
10723                .unwrap();
10724        assert_eq!(doc.content[0].node_type, "blockCard");
10725        assert_eq!(
10726            doc.content[0].attrs.as_ref().unwrap()["url"],
10727            "https://new.example.com"
10728        );
10729    }
10730
10731    #[test]
10732    fn block_card_attr_form_with_layout_and_width() {
10733        // Attr-URL form combined with layout/width attrs — ensures all
10734        // sibling attrs still pass through after the URL lookup.
10735        let doc =
10736            markdown_to_adf(r#"::card[]{url="https://example.com/a]b" layout=wide width=80}"#)
10737                .unwrap();
10738        let attrs = doc.content[0].attrs.as_ref().unwrap();
10739        assert_eq!(attrs["url"], "https://example.com/a]b");
10740        assert_eq!(attrs["layout"], "wide");
10741        assert_eq!(attrs["width"], 80);
10742    }
10743
10744    #[test]
10745    fn inline_card_issue_553_reproducer() {
10746        // Verbatim reproducer from issue #553: an inlineCard in a paragraph
10747        // with preceding text must round-trip as an inlineCard, not degrade to
10748        // a text node with a link mark.
10749        let adf_json = r#"{
10750            "version": 1,
10751            "type": "doc",
10752            "content": [{
10753                "type": "paragraph",
10754                "content": [
10755                    {"type": "text", "text": "See the related page: "},
10756                    {"type": "inlineCard", "attrs": {
10757                        "url": "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10758                    }}
10759                ]
10760            }]
10761        }"#;
10762        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10763        let jfm = adf_to_markdown(&adf).unwrap();
10764        let rt = markdown_to_adf(&jfm).unwrap();
10765        let content = rt.content[0].content.as_ref().unwrap();
10766        assert_eq!(content.len(), 2);
10767        assert_eq!(content[0].node_type, "text");
10768        assert_eq!(content[1].node_type, "inlineCard");
10769        assert_eq!(
10770            content[1].attrs.as_ref().unwrap()["url"],
10771            "https://example.atlassian.net/wiki/spaces/ENG/pages/12345678"
10772        );
10773    }
10774
10775    #[test]
10776    fn inline_card_attr_form_parses_even_without_renderer() {
10777        // Directly parsing `:card[]{url="..."}` should yield an inlineCard.
10778        let doc = markdown_to_adf(r#":card[]{url="https://example.com/a"}"#).unwrap();
10779        let node = &doc.content[0].content.as_ref().unwrap()[0];
10780        assert_eq!(node.node_type, "inlineCard");
10781        assert_eq!(node.attrs.as_ref().unwrap()["url"], "https://example.com/a");
10782    }
10783
10784    #[test]
10785    fn inline_card_attr_form_url_overrides_content() {
10786        // When both bracket-content and `url=` attr are present, attr wins.
10787        // This keeps the parser forgiving of hand-edited JFM where a user
10788        // copied an old bracket form but added attrs.
10789        let doc =
10790            markdown_to_adf(r#":card[https://old.example.com]{url="https://new.example.com"}"#)
10791                .unwrap();
10792        let node = &doc.content[0].content.as_ref().unwrap()[0];
10793        assert_eq!(node.node_type, "inlineCard");
10794        assert_eq!(
10795            node.attrs.as_ref().unwrap()["url"],
10796            "https://new.example.com"
10797        );
10798    }
10799
10800    // ── Issue #553 (updated): mark-wrapped URL must not become inlineCard ──
10801
10802    #[test]
10803    fn url_with_link_and_underline_marks_round_trip() {
10804        // Issue #553 (updated reproducer): a `text` node whose content is a
10805        // URL and that carries both `link` and `underline` marks must round-
10806        // trip as text+marks, not be promoted to an `inlineCard`.
10807        let adf_json = r#"{
10808            "version": 1,
10809            "type": "doc",
10810            "content": [{
10811                "type": "paragraph",
10812                "content": [
10813                    {"type": "text", "text": "See results at: "},
10814                    {"type": "text",
10815                     "text": "https://example.com/projects/abc123/analytics",
10816                     "marks": [
10817                        {"type": "link", "attrs": {"href": "https://example.com/projects/abc123/analytics"}},
10818                        {"type": "underline"}
10819                     ]},
10820                    {"type": "text", "text": " for details."}
10821                ]
10822            }]
10823        }"#;
10824        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10825        let jfm = adf_to_markdown(&adf).unwrap();
10826        let rt = markdown_to_adf(&jfm).unwrap();
10827        let content = rt.content[0].content.as_ref().unwrap();
10828        assert_eq!(
10829            content.len(),
10830            3,
10831            "expected 3 inline nodes, got: {content:?}"
10832        );
10833        assert_eq!(content[0].node_type, "text");
10834        assert_eq!(
10835            content[1].node_type, "text",
10836            "must stay text, not inlineCard"
10837        );
10838        assert_eq!(
10839            content[1].text.as_deref(),
10840            Some("https://example.com/projects/abc123/analytics")
10841        );
10842        let mark_types: Vec<&str> = content[1]
10843            .marks
10844            .as_deref()
10845            .unwrap_or(&[])
10846            .iter()
10847            .map(|m| m.mark_type.as_str())
10848            .collect();
10849        assert_eq!(mark_types, vec!["link", "underline"], "marks lost");
10850        assert_eq!(content[2].node_type, "text");
10851    }
10852
10853    #[test]
10854    fn url_inside_bracketed_span_stays_text() {
10855        // `[URL]{underline}` in JFM means "underline this URL text", not
10856        // "create a smart link that's underlined".  The nested parse_inline
10857        // call must not auto-promote the bare URL to an inlineCard.
10858        let doc = markdown_to_adf("[https://example.com]{underline}").unwrap();
10859        let node = &doc.content[0].content.as_ref().unwrap()[0];
10860        assert_eq!(node.node_type, "text");
10861        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10862        let mark_types: Vec<&str> = node
10863            .marks
10864            .as_deref()
10865            .unwrap_or(&[])
10866            .iter()
10867            .map(|m| m.mark_type.as_str())
10868            .collect();
10869        assert_eq!(mark_types, vec!["underline"]);
10870    }
10871
10872    #[test]
10873    fn url_inside_emphasis_stays_text() {
10874        // Bold, italic, and strike-wrapped URLs should remain as text nodes,
10875        // not get promoted to inlineCards by the nested inline parser.
10876        for (md, mark) in [
10877            ("**https://example.com**", "strong"),
10878            ("*https://example.com*", "em"),
10879            ("~~https://example.com~~", "strike"),
10880        ] {
10881            let doc = markdown_to_adf(md).unwrap();
10882            let node = &doc.content[0].content.as_ref().unwrap()[0];
10883            assert_eq!(node.node_type, "text", "md={md}: must be text");
10884            assert_eq!(node.text.as_deref(), Some("https://example.com"));
10885            let mark_types: Vec<&str> = node
10886                .marks
10887                .as_deref()
10888                .unwrap_or(&[])
10889                .iter()
10890                .map(|m| m.mark_type.as_str())
10891                .collect();
10892            assert_eq!(mark_types, vec![mark], "md={md}: wrong marks");
10893        }
10894    }
10895
10896    #[test]
10897    fn url_inside_span_directive_stays_text() {
10898        // `:span[URL]{color=red}` should not promote the URL to an inlineCard.
10899        let doc = markdown_to_adf(":span[https://example.com]{color=red}").unwrap();
10900        let node = &doc.content[0].content.as_ref().unwrap()[0];
10901        assert_eq!(node.node_type, "text");
10902        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10903        let mark = &node.marks.as_ref().unwrap()[0];
10904        assert_eq!(mark.mark_type, "textColor");
10905    }
10906
10907    #[test]
10908    fn url_as_link_text_with_underline_after_link_mark_order() {
10909        // Reverse mark order — underline appears BEFORE link in the ADF array.
10910        // The JFM form is `[[text](url)]{underline}`; the nested parser must
10911        // still keep the URL as plain text.
10912        let adf_json = r#"{
10913            "version": 1,
10914            "type": "doc",
10915            "content": [{
10916                "type": "paragraph",
10917                "content": [
10918                    {"type": "text",
10919                     "text": "https://example.com",
10920                     "marks": [
10921                        {"type": "underline"},
10922                        {"type": "link", "attrs": {"href": "https://example.com"}}
10923                     ]}
10924                ]
10925            }]
10926        }"#;
10927        let adf: AdfDocument = serde_json::from_str(adf_json).unwrap();
10928        let jfm = adf_to_markdown(&adf).unwrap();
10929        let rt = markdown_to_adf(&jfm).unwrap();
10930        let node = &rt.content[0].content.as_ref().unwrap()[0];
10931        assert_eq!(node.node_type, "text", "must stay text, got: {node:?}");
10932        assert_eq!(node.text.as_deref(), Some("https://example.com"));
10933        let mark_types: Vec<&str> = node
10934            .marks
10935            .as_deref()
10936            .unwrap_or(&[])
10937            .iter()
10938            .map(|m| m.mark_type.as_str())
10939            .collect();
10940        assert_eq!(mark_types, vec!["underline", "link"]);
10941    }
10942
10943    #[test]
10944    fn bare_url_at_top_level_still_becomes_inline_card() {
10945        // Regression guard: the suppression only applies inside mark-wrapping
10946        // constructs.  A bare URL in ordinary paragraph text must still be
10947        // detected and promoted to an inlineCard.
10948        let doc = markdown_to_adf("Visit https://example.com today").unwrap();
10949        let content = doc.content[0].content.as_ref().unwrap();
10950        assert_eq!(content.len(), 3);
10951        assert_eq!(content[0].node_type, "text");
10952        assert_eq!(content[1].node_type, "inlineCard");
10953        assert_eq!(
10954            content[1].attrs.as_ref().unwrap()["url"],
10955            "https://example.com"
10956        );
10957        assert_eq!(content[2].node_type, "text");
10958    }
10959
10960    // ── Block-level attribute marks (Tier 5/6) ───────────────────────
10961
10962    #[test]
10963    fn paragraph_align_center() {
10964        let md = "Centered text.\n{align=center}";
10965        let doc = markdown_to_adf(md).unwrap();
10966        let marks = doc.content[0].marks.as_ref().unwrap();
10967        assert_eq!(marks[0].mark_type, "alignment");
10968        assert_eq!(marks[0].attrs.as_ref().unwrap()["align"], "center");
10969    }
10970
10971    #[test]
10972    fn adf_alignment_to_markdown() {
10973        let mut node = AdfNode::paragraph(vec![AdfNode::text("Centered.")]);
10974        node.marks = Some(vec![AdfMark::alignment("center")]);
10975        let doc = AdfDocument {
10976            version: 1,
10977            doc_type: "doc".to_string(),
10978            content: vec![node],
10979        };
10980        let md = adf_to_markdown(&doc).unwrap();
10981        assert!(md.contains("Centered."));
10982        assert!(md.contains("{align=center}"));
10983    }
10984
10985    #[test]
10986    fn round_trip_alignment() {
10987        let md = "Centered.\n{align=center}\n";
10988        let doc = markdown_to_adf(md).unwrap();
10989        let result = adf_to_markdown(&doc).unwrap();
10990        assert!(result.contains("{align=center}"));
10991    }
10992
10993    #[test]
10994    fn paragraph_indent() {
10995        let md = "Indented.\n{indent=2}";
10996        let doc = markdown_to_adf(md).unwrap();
10997        let marks = doc.content[0].marks.as_ref().unwrap();
10998        assert_eq!(marks[0].mark_type, "indentation");
10999        assert_eq!(marks[0].attrs.as_ref().unwrap()["level"], 2);
11000    }
11001
11002    #[test]
11003    fn code_block_breakout() {
11004        let md = "```python\ndef f(): pass\n```\n{breakout=wide}";
11005        let doc = markdown_to_adf(md).unwrap();
11006        let marks = doc.content[0].marks.as_ref().unwrap();
11007        assert_eq!(marks[0].mark_type, "breakout");
11008        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
11009        assert!(marks[0].attrs.as_ref().unwrap().get("width").is_none());
11010    }
11011
11012    #[test]
11013    fn code_block_breakout_with_width() {
11014        let md = "```python\ndef f(): pass\n```\n{breakout=wide breakoutWidth=1200}";
11015        let doc = markdown_to_adf(md).unwrap();
11016        let marks = doc.content[0].marks.as_ref().unwrap();
11017        assert_eq!(marks[0].mark_type, "breakout");
11018        assert_eq!(marks[0].attrs.as_ref().unwrap()["mode"], "wide");
11019        assert_eq!(marks[0].attrs.as_ref().unwrap()["width"], 1200);
11020    }
11021
11022    #[test]
11023    fn adf_breakout_to_markdown() {
11024        let mut node = AdfNode::code_block(Some("python"), "pass");
11025        node.marks = Some(vec![AdfMark::breakout("wide", None)]);
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("{breakout=wide}"));
11033        assert!(!md.contains("breakoutWidth"));
11034    }
11035
11036    #[test]
11037    fn adf_breakout_with_width_to_markdown() {
11038        let mut node = AdfNode::code_block(Some("python"), "pass");
11039        node.marks = Some(vec![AdfMark::breakout("wide", Some(1200))]);
11040        let doc = AdfDocument {
11041            version: 1,
11042            doc_type: "doc".to_string(),
11043            content: vec![node],
11044        };
11045        let md = adf_to_markdown(&doc).unwrap();
11046        assert!(md.contains("breakout=wide"));
11047        assert!(md.contains("breakoutWidth=1200"));
11048    }
11049
11050    #[test]
11051    fn breakout_width_round_trip() {
11052        let adf_json = r#"{"version":1,"type":"doc","content":[{
11053            "type":"codeBlock",
11054            "attrs":{"language":"text"},
11055            "marks":[{"type":"breakout","attrs":{"mode":"wide","width":1200}}],
11056            "content":[{"type":"text","text":"some code"}]
11057        }]}"#;
11058        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11059        let md = adf_to_markdown(&doc).unwrap();
11060        assert!(md.contains("breakout=wide"));
11061        assert!(md.contains("breakoutWidth=1200"));
11062        let round_tripped = markdown_to_adf(&md).unwrap();
11063        let marks = round_tripped.content[0].marks.as_ref().unwrap();
11064        let breakout = marks.iter().find(|m| m.mark_type == "breakout").unwrap();
11065        assert_eq!(breakout.attrs.as_ref().unwrap()["mode"], "wide");
11066        assert_eq!(breakout.attrs.as_ref().unwrap()["width"], 1200);
11067    }
11068
11069    // ── Attribute extensions — media & table (Tier 5) ────────────────
11070
11071    #[test]
11072    fn image_with_layout_attrs() {
11073        let doc = markdown_to_adf("![alt](url){layout=wide width=80}").unwrap();
11074        let node = &doc.content[0];
11075        assert_eq!(node.node_type, "mediaSingle");
11076        let attrs = node.attrs.as_ref().unwrap();
11077        assert_eq!(attrs["layout"], "wide");
11078        assert_eq!(attrs["width"], 80);
11079    }
11080
11081    #[test]
11082    fn adf_image_with_layout_to_markdown() {
11083        let mut node = AdfNode::media_single("url", Some("alt"));
11084        node.attrs.as_mut().unwrap()["layout"] = serde_json::json!("wide");
11085        node.attrs.as_mut().unwrap()["width"] = serde_json::json!(80);
11086        let doc = AdfDocument {
11087            version: 1,
11088            doc_type: "doc".to_string(),
11089            content: vec![node],
11090        };
11091        let md = adf_to_markdown(&doc).unwrap();
11092        assert!(md.contains("![alt](url){layout=wide width=80}"));
11093    }
11094
11095    #[test]
11096    fn table_with_layout_attrs() {
11097        let md = "| H |\n| --- |\n| C |\n{layout=wide numbered}";
11098        let doc = markdown_to_adf(md).unwrap();
11099        let table = &doc.content[0];
11100        assert_eq!(table.node_type, "table");
11101        let attrs = table.attrs.as_ref().unwrap();
11102        assert_eq!(attrs["layout"], "wide");
11103        assert_eq!(attrs["isNumberColumnEnabled"], true);
11104    }
11105
11106    #[test]
11107    fn adf_table_with_attrs_to_markdown() {
11108        let mut table = AdfNode::table(vec![
11109            AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
11110                AdfNode::text("H"),
11111            ])])]),
11112            AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
11113                AdfNode::text("C"),
11114            ])])]),
11115        ]);
11116        table.attrs = Some(serde_json::json!({"layout": "wide", "isNumberColumnEnabled": true}));
11117        let doc = AdfDocument {
11118            version: 1,
11119            doc_type: "doc".to_string(),
11120            content: vec![table],
11121        };
11122        let md = adf_to_markdown(&doc).unwrap();
11123        assert!(md.contains("{layout=wide numbered}"));
11124    }
11125
11126    // ── Attribute extensions — inline marks (Tier 5) ─────────────────
11127
11128    #[test]
11129    fn underline_bracketed_span() {
11130        let doc = markdown_to_adf("This is [underlined text]{underline} here.").unwrap();
11131        let content = doc.content[0].content.as_ref().unwrap();
11132        assert_eq!(content[1].text.as_deref(), Some("underlined text"));
11133        let marks = content[1].marks.as_ref().unwrap();
11134        assert_eq!(marks[0].mark_type, "underline");
11135    }
11136
11137    #[test]
11138    fn adf_underline_to_markdown() {
11139        let doc = AdfDocument {
11140            version: 1,
11141            doc_type: "doc".to_string(),
11142            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11143                "underlined",
11144                vec![AdfMark::underline()],
11145            )])],
11146        };
11147        let md = adf_to_markdown(&doc).unwrap();
11148        assert!(md.contains("[underlined]{underline}"));
11149    }
11150
11151    #[test]
11152    fn round_trip_underline() {
11153        let md = "This is [underlined text]{underline} here.\n";
11154        let doc = markdown_to_adf(md).unwrap();
11155        let result = adf_to_markdown(&doc).unwrap();
11156        assert!(result.contains("[underlined text]{underline}"));
11157    }
11158
11159    #[test]
11160    fn mark_ordering_underline_strong_preserved() {
11161        // Issue #383: mark ordering was non-deterministic
11162        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11163          {"type":"text","text":"bold and underlined","marks":[{"type":"underline"},{"type":"strong"}]}
11164        ]}]}"#;
11165        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11166        let md = adf_to_markdown(&doc).unwrap();
11167        let round_tripped = markdown_to_adf(&md).unwrap();
11168        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11169        let mark_types: Vec<&str> = node
11170            .marks
11171            .as_ref()
11172            .unwrap()
11173            .iter()
11174            .map(|m| m.mark_type.as_str())
11175            .collect();
11176        assert_eq!(
11177            mark_types,
11178            vec!["underline", "strong"],
11179            "mark order should be preserved, got: {mark_types:?}"
11180        );
11181    }
11182
11183    #[test]
11184    fn mark_ordering_link_strong_preserved() {
11185        // Issue #403: link+strong mark order was swapped on round-trip
11186        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11187          {"type":"text","text":"bold link","marks":[
11188            {"type":"link","attrs":{"href":"https://example.com"}},
11189            {"type":"strong"}
11190          ]}
11191        ]}]}"#;
11192        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11193        let md = adf_to_markdown(&doc).unwrap();
11194        let round_tripped = markdown_to_adf(&md).unwrap();
11195        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11196        let mark_types: Vec<&str> = node
11197            .marks
11198            .as_ref()
11199            .unwrap()
11200            .iter()
11201            .map(|m| m.mark_type.as_str())
11202            .collect();
11203        assert_eq!(
11204            mark_types,
11205            vec!["link", "strong"],
11206            "mark order should be preserved, got: {mark_types:?}"
11207        );
11208    }
11209
11210    #[test]
11211    fn mark_ordering_link_textcolor_preserved() {
11212        // Issue #403 comment: link+textColor mark order was swapped on round-trip
11213        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11214          {"type":"text","text":"red link","marks":[
11215            {"type":"link","attrs":{"href":"https://example.com"}},
11216            {"type":"textColor","attrs":{"color":"#ff0000"}}
11217          ]}
11218        ]}]}"##;
11219        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11220        let md = adf_to_markdown(&doc).unwrap();
11221        let round_tripped = markdown_to_adf(&md).unwrap();
11222        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11223        let mark_types: Vec<&str> = node
11224            .marks
11225            .as_ref()
11226            .unwrap()
11227            .iter()
11228            .map(|m| m.mark_type.as_str())
11229            .collect();
11230        assert_eq!(
11231            mark_types,
11232            vec!["link", "textColor"],
11233            "mark order should be preserved, got: {mark_types:?}"
11234        );
11235    }
11236
11237    #[test]
11238    fn mark_ordering_link_em_preserved() {
11239        // Issue #403: link+em mark order should be preserved
11240        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11241          {"type":"text","text":"italic link","marks":[
11242            {"type":"link","attrs":{"href":"https://example.com"}},
11243            {"type":"em"}
11244          ]}
11245        ]}]}"#;
11246        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11247        let md = adf_to_markdown(&doc).unwrap();
11248        let round_tripped = markdown_to_adf(&md).unwrap();
11249        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11250        let mark_types: Vec<&str> = node
11251            .marks
11252            .as_ref()
11253            .unwrap()
11254            .iter()
11255            .map(|m| m.mark_type.as_str())
11256            .collect();
11257        assert_eq!(
11258            mark_types,
11259            vec!["link", "em"],
11260            "mark order should be preserved, got: {mark_types:?}"
11261        );
11262    }
11263
11264    #[test]
11265    fn mark_ordering_link_strike_preserved() {
11266        // Issue #403: link+strike mark order should be preserved
11267        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11268          {"type":"text","text":"struck link","marks":[
11269            {"type":"link","attrs":{"href":"https://example.com"}},
11270            {"type":"strike"}
11271          ]}
11272        ]}]}"#;
11273        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11274        let md = adf_to_markdown(&doc).unwrap();
11275        let round_tripped = markdown_to_adf(&md).unwrap();
11276        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11277        let mark_types: Vec<&str> = node
11278            .marks
11279            .as_ref()
11280            .unwrap()
11281            .iter()
11282            .map(|m| m.mark_type.as_str())
11283            .collect();
11284        assert_eq!(
11285            mark_types,
11286            vec!["link", "strike"],
11287            "mark order should be preserved, got: {mark_types:?}"
11288        );
11289    }
11290
11291    #[test]
11292    fn mark_ordering_strong_link_preserved() {
11293        // Issue #403: [strong, link] order must also be preserved (reverse direction)
11294        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11295          {"type":"text","text":"bold link","marks":[
11296            {"type":"strong"},
11297            {"type":"link","attrs":{"href":"https://example.com"}}
11298          ]}
11299        ]}]}"#;
11300        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11301        let md = adf_to_markdown(&doc).unwrap();
11302        let round_tripped = markdown_to_adf(&md).unwrap();
11303        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11304        let mark_types: Vec<&str> = node
11305            .marks
11306            .as_ref()
11307            .unwrap()
11308            .iter()
11309            .map(|m| m.mark_type.as_str())
11310            .collect();
11311        assert_eq!(
11312            mark_types,
11313            vec!["strong", "link"],
11314            "mark order should be preserved, got: {mark_types:?}"
11315        );
11316    }
11317
11318    #[test]
11319    fn mark_ordering_em_link_preserved() {
11320        // Issue #403: [em, link] order must also be preserved
11321        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11322          {"type":"text","text":"italic link","marks":[
11323            {"type":"em"},
11324            {"type":"link","attrs":{"href":"https://example.com"}}
11325          ]}
11326        ]}]}"#;
11327        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11328        let md = adf_to_markdown(&doc).unwrap();
11329        let round_tripped = markdown_to_adf(&md).unwrap();
11330        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11331        let mark_types: Vec<&str> = node
11332            .marks
11333            .as_ref()
11334            .unwrap()
11335            .iter()
11336            .map(|m| m.mark_type.as_str())
11337            .collect();
11338        assert_eq!(
11339            mark_types,
11340            vec!["em", "link"],
11341            "mark order should be preserved, got: {mark_types:?}"
11342        );
11343    }
11344
11345    #[test]
11346    fn mark_ordering_strike_link_preserved() {
11347        // Issue #403: [strike, link] order must also be preserved
11348        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11349          {"type":"text","text":"struck link","marks":[
11350            {"type":"strike"},
11351            {"type":"link","attrs":{"href":"https://example.com"}}
11352          ]}
11353        ]}]}"#;
11354        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11355        let md = adf_to_markdown(&doc).unwrap();
11356        let round_tripped = markdown_to_adf(&md).unwrap();
11357        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11358        let mark_types: Vec<&str> = node
11359            .marks
11360            .as_ref()
11361            .unwrap()
11362            .iter()
11363            .map(|m| m.mark_type.as_str())
11364            .collect();
11365        assert_eq!(
11366            mark_types,
11367            vec!["strike", "link"],
11368            "mark order should be preserved, got: {mark_types:?}"
11369        );
11370    }
11371
11372    #[test]
11373    fn mark_ordering_underline_link_preserved() {
11374        // Issue #403: [underline, link] order must be preserved
11375        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11376          {"type":"text","text":"click here","marks":[
11377            {"type":"underline"},
11378            {"type":"link","attrs":{"href":"https://example.com"}}
11379          ]}
11380        ]}]}"#;
11381        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11382        let md = adf_to_markdown(&doc).unwrap();
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!["underline", "link"],
11395            "mark order should be preserved, got: {mark_types:?}"
11396        );
11397    }
11398
11399    #[test]
11400    fn mark_ordering_textcolor_link_preserved() {
11401        // Issue #403: [textColor, link] order must be preserved
11402        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11403          {"type":"text","text":"red link","marks":[
11404            {"type":"textColor","attrs":{"color":"#ff0000"}},
11405            {"type":"link","attrs":{"href":"https://example.com"}}
11406          ]}
11407        ]}]}"##;
11408        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11409        let md = adf_to_markdown(&doc).unwrap();
11410        let round_tripped = markdown_to_adf(&md).unwrap();
11411        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11412        let mark_types: Vec<&str> = node
11413            .marks
11414            .as_ref()
11415            .unwrap()
11416            .iter()
11417            .map(|m| m.mark_type.as_str())
11418            .collect();
11419        assert_eq!(
11420            mark_types,
11421            vec!["textColor", "link"],
11422            "mark order should be preserved, got: {mark_types:?}"
11423        );
11424    }
11425
11426    #[test]
11427    fn mark_ordering_link_underline_preserved() {
11428        // Issue #403: [link, underline] order must be preserved (link wraps bracketed span)
11429        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11430          {"type":"text","text":"click here","marks":[
11431            {"type":"link","attrs":{"href":"https://example.com"}},
11432            {"type":"underline"}
11433          ]}
11434        ]}]}"#;
11435        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11436        let md = adf_to_markdown(&doc).unwrap();
11437        // Link should wrap the underline bracketed span: [[click here]{underline}](url)
11438        assert!(
11439            md.contains("](https://example.com)"),
11440            "should have link: {md}"
11441        );
11442        assert!(md.contains("underline"), "should have underline: {md}");
11443        let round_tripped = markdown_to_adf(&md).unwrap();
11444        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11445        let mark_types: Vec<&str> = node
11446            .marks
11447            .as_ref()
11448            .unwrap()
11449            .iter()
11450            .map(|m| m.mark_type.as_str())
11451            .collect();
11452        assert_eq!(
11453            mark_types,
11454            vec!["link", "underline"],
11455            "mark order should be preserved, got: {mark_types:?}"
11456        );
11457    }
11458
11459    #[test]
11460    fn mark_ordering_underline_strong_link_preserved() {
11461        // Issue #491: [underline, strong, link] reordered to [strong, underline, link]
11462        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11463          {"type":"text","text":"bold underlined link","marks":[
11464            {"type":"underline"},
11465            {"type":"strong"},
11466            {"type":"link","attrs":{"href":"https://example.com/page"}}
11467          ]}
11468        ]}]}"#;
11469        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11470        let md = adf_to_markdown(&doc).unwrap();
11471        let round_tripped = markdown_to_adf(&md).unwrap();
11472        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11473        let mark_types: Vec<&str> = node
11474            .marks
11475            .as_ref()
11476            .unwrap()
11477            .iter()
11478            .map(|m| m.mark_type.as_str())
11479            .collect();
11480        assert_eq!(
11481            mark_types,
11482            vec!["underline", "strong", "link"],
11483            "mark order should be preserved, got: {mark_types:?}"
11484        );
11485    }
11486
11487    #[test]
11488    fn mark_ordering_strong_underline_link_preserved() {
11489        // Issue #491: verify [strong, underline, link] is preserved
11490        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11491          {"type":"text","text":"bold underlined link","marks":[
11492            {"type":"strong"},
11493            {"type":"underline"},
11494            {"type":"link","attrs":{"href":"https://example.com/page"}}
11495          ]}
11496        ]}]}"#;
11497        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11498        let md = adf_to_markdown(&doc).unwrap();
11499        let round_tripped = markdown_to_adf(&md).unwrap();
11500        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11501        let mark_types: Vec<&str> = node
11502            .marks
11503            .as_ref()
11504            .unwrap()
11505            .iter()
11506            .map(|m| m.mark_type.as_str())
11507            .collect();
11508        assert_eq!(
11509            mark_types,
11510            vec!["strong", "underline", "link"],
11511            "mark order should be preserved, got: {mark_types:?}"
11512        );
11513    }
11514
11515    #[test]
11516    fn mark_ordering_underline_em_link_preserved() {
11517        // Issue #491: verify [underline, em, link] is preserved
11518        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11519          {"type":"text","text":"italic underlined link","marks":[
11520            {"type":"underline"},
11521            {"type":"em"},
11522            {"type":"link","attrs":{"href":"https://example.com/page"}}
11523          ]}
11524        ]}]}"#;
11525        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11526        let md = adf_to_markdown(&doc).unwrap();
11527        let round_tripped = markdown_to_adf(&md).unwrap();
11528        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11529        let mark_types: Vec<&str> = node
11530            .marks
11531            .as_ref()
11532            .unwrap()
11533            .iter()
11534            .map(|m| m.mark_type.as_str())
11535            .collect();
11536        assert_eq!(
11537            mark_types,
11538            vec!["underline", "em", "link"],
11539            "mark order should be preserved, got: {mark_types:?}"
11540        );
11541    }
11542
11543    #[test]
11544    fn mark_ordering_underline_strike_link_preserved() {
11545        // Issue #491: verify [underline, strike, link] is preserved
11546        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11547          {"type":"text","text":"struck underlined link","marks":[
11548            {"type":"underline"},
11549            {"type":"strike"},
11550            {"type":"link","attrs":{"href":"https://example.com/page"}}
11551          ]}
11552        ]}]}"#;
11553        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11554        let md = adf_to_markdown(&doc).unwrap();
11555        let round_tripped = markdown_to_adf(&md).unwrap();
11556        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11557        let mark_types: Vec<&str> = node
11558            .marks
11559            .as_ref()
11560            .unwrap()
11561            .iter()
11562            .map(|m| m.mark_type.as_str())
11563            .collect();
11564        assert_eq!(
11565            mark_types,
11566            vec!["underline", "strike", "link"],
11567            "mark order should be preserved, got: {mark_types:?}"
11568        );
11569    }
11570
11571    #[test]
11572    fn mark_ordering_underline_strong_em_link_preserved() {
11573        // Issue #491: verify four-mark combo [underline, strong, em, link] is preserved
11574        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11575          {"type":"text","text":"all the marks","marks":[
11576            {"type":"underline"},
11577            {"type":"strong"},
11578            {"type":"em"},
11579            {"type":"link","attrs":{"href":"https://example.com/page"}}
11580          ]}
11581        ]}]}"#;
11582        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11583        let md = adf_to_markdown(&doc).unwrap();
11584        let round_tripped = markdown_to_adf(&md).unwrap();
11585        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11586        let mark_types: Vec<&str> = node
11587            .marks
11588            .as_ref()
11589            .unwrap()
11590            .iter()
11591            .map(|m| m.mark_type.as_str())
11592            .collect();
11593        assert_eq!(
11594            mark_types,
11595            vec!["underline", "strong", "em", "link"],
11596            "mark order should be preserved, got: {mark_types:?}"
11597        );
11598    }
11599
11600    #[test]
11601    fn em_strong_round_trip() {
11602        // Issue #401: em mark dropped when combined with strong
11603        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11604          {"type":"text","text":"bold and italic","marks":[{"type":"strong"},{"type":"em"}]}
11605        ]}]}"#;
11606        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11607        let md = adf_to_markdown(&doc).unwrap();
11608        assert_eq!(md.trim(), "***bold and italic***");
11609        let round_tripped = markdown_to_adf(&md).unwrap();
11610        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11611        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11612        let mark_types: Vec<&str> = node
11613            .marks
11614            .as_ref()
11615            .unwrap()
11616            .iter()
11617            .map(|m| m.mark_type.as_str())
11618            .collect();
11619        assert_eq!(
11620            mark_types,
11621            vec!["strong", "em"],
11622            "both strong and em marks should be preserved, got: {mark_types:?}"
11623        );
11624    }
11625
11626    #[test]
11627    fn em_strong_round_trip_em_first() {
11628        // Issue #549: [em, strong] mark order must be preserved on round-trip.
11629        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
11630          {"type":"text","text":"italic and bold","marks":[{"type":"em"},{"type":"strong"}]}
11631        ]}]}"#;
11632        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
11633        let md = adf_to_markdown(&doc).unwrap();
11634        let round_tripped = markdown_to_adf(&md).unwrap();
11635        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11636        assert_eq!(node.text.as_deref(), Some("italic and bold"));
11637        let mark_types: Vec<&str> = node
11638            .marks
11639            .as_ref()
11640            .unwrap()
11641            .iter()
11642            .map(|m| m.mark_type.as_str())
11643            .collect();
11644        assert_eq!(
11645            mark_types,
11646            vec!["em", "strong"],
11647            "mark order [em, strong] should be preserved, got: {mark_types:?}"
11648        );
11649    }
11650
11651    /// Round-trips an inline text node with the given marks through ADF → JFM → ADF
11652    /// and asserts the resulting mark types match `expected`.
11653    fn assert_mark_order_round_trip(marks: Vec<AdfMark>, expected: &[&str]) {
11654        let doc = AdfDocument {
11655            version: 1,
11656            doc_type: "doc".to_string(),
11657            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11658                "text", marks,
11659            )])],
11660        };
11661        let md = adf_to_markdown(&doc).unwrap();
11662        let round_tripped = markdown_to_adf(&md).unwrap();
11663        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11664        let mark_types: Vec<&str> = node
11665            .marks
11666            .as_ref()
11667            .expect("round-tripped node should have marks")
11668            .iter()
11669            .map(|m| m.mark_type.as_str())
11670            .collect();
11671        assert_eq!(
11672            mark_types, expected,
11673            "marks should round-trip in order via {md:?}"
11674        );
11675    }
11676
11677    #[test]
11678    fn round_trip_em_strong_mark_order() {
11679        // Issue #549: em + strong in either order must round-trip.
11680        assert_mark_order_round_trip(vec![AdfMark::em(), AdfMark::strong()], &["em", "strong"]);
11681        assert_mark_order_round_trip(vec![AdfMark::strong(), AdfMark::em()], &["strong", "em"]);
11682    }
11683
11684    #[test]
11685    fn round_trip_strong_underline_mark_order() {
11686        // Issue #549: strong + underline in either order must round-trip.
11687        assert_mark_order_round_trip(
11688            vec![AdfMark::strong(), AdfMark::underline()],
11689            &["strong", "underline"],
11690        );
11691        assert_mark_order_round_trip(
11692            vec![AdfMark::underline(), AdfMark::strong()],
11693            &["underline", "strong"],
11694        );
11695    }
11696
11697    #[test]
11698    fn round_trip_em_underline_mark_order() {
11699        assert_mark_order_round_trip(
11700            vec![AdfMark::em(), AdfMark::underline()],
11701            &["em", "underline"],
11702        );
11703        assert_mark_order_round_trip(
11704            vec![AdfMark::underline(), AdfMark::em()],
11705            &["underline", "em"],
11706        );
11707    }
11708
11709    #[test]
11710    fn round_trip_strike_strong_em_permutations() {
11711        // Each permutation of {strike, strong, em} must round-trip the mark order
11712        // exactly, because the Atlassian ADF spec does not define a canonical mark
11713        // ordering and we preserve whatever ordering Jira delivered.
11714        assert_mark_order_round_trip(
11715            vec![AdfMark::strike(), AdfMark::strong(), AdfMark::em()],
11716            &["strike", "strong", "em"],
11717        );
11718        assert_mark_order_round_trip(
11719            vec![AdfMark::strike(), AdfMark::em(), AdfMark::strong()],
11720            &["strike", "em", "strong"],
11721        );
11722        assert_mark_order_round_trip(
11723            vec![AdfMark::strong(), AdfMark::strike(), AdfMark::em()],
11724            &["strong", "strike", "em"],
11725        );
11726        assert_mark_order_round_trip(
11727            vec![AdfMark::strong(), AdfMark::em(), AdfMark::strike()],
11728            &["strong", "em", "strike"],
11729        );
11730        assert_mark_order_round_trip(
11731            vec![AdfMark::em(), AdfMark::strike(), AdfMark::strong()],
11732            &["em", "strike", "strong"],
11733        );
11734        assert_mark_order_round_trip(
11735            vec![AdfMark::em(), AdfMark::strong(), AdfMark::strike()],
11736            &["em", "strong", "strike"],
11737        );
11738    }
11739
11740    #[test]
11741    fn round_trip_underline_nested_with_strong_em() {
11742        // Underline may sit outside, between, or inside strong/em — each position
11743        // must round-trip.
11744        assert_mark_order_round_trip(
11745            vec![AdfMark::underline(), AdfMark::strong(), AdfMark::em()],
11746            &["underline", "strong", "em"],
11747        );
11748        assert_mark_order_round_trip(
11749            vec![AdfMark::strong(), AdfMark::underline(), AdfMark::em()],
11750            &["strong", "underline", "em"],
11751        );
11752        assert_mark_order_round_trip(
11753            vec![AdfMark::strong(), AdfMark::em(), AdfMark::underline()],
11754            &["strong", "em", "underline"],
11755        );
11756    }
11757
11758    #[test]
11759    fn round_trip_span_attr_order_preserved() {
11760        // Issue #549: the `:span` directive always parses color/bg/subsup
11761        // attrs in a fixed order, so non-canonical orderings must be emitted
11762        // as nested :span wrappers rather than a single merged wrapper.
11763        assert_mark_order_round_trip(
11764            vec![
11765                AdfMark::background_color("#ffff00"),
11766                AdfMark::text_color("#ff0000"),
11767            ],
11768            &["backgroundColor", "textColor"],
11769        );
11770        assert_mark_order_round_trip(
11771            vec![AdfMark::subsup("sub"), AdfMark::text_color("#ff0000")],
11772            &["subsup", "textColor"],
11773        );
11774        assert_mark_order_round_trip(
11775            vec![
11776                AdfMark::text_color("#ff0000"),
11777                AdfMark::background_color("#ffff00"),
11778            ],
11779            &["textColor", "backgroundColor"],
11780        );
11781    }
11782
11783    #[test]
11784    fn round_trip_annotation_before_underline() {
11785        // Issue #549: the bracketed-span parser reads `underline` before any
11786        // annotation-ids, so `[annotation, underline]` must be emitted as
11787        // nested wrappers rather than one merged `[text]{underline annotation-id=X}`.
11788        assert_mark_order_round_trip(
11789            vec![
11790                AdfMark::annotation("ann-1", "inlineComment"),
11791                AdfMark::underline(),
11792            ],
11793            &["annotation", "underline"],
11794        );
11795        assert_mark_order_round_trip(
11796            vec![
11797                AdfMark::annotation("ann-1", "inlineComment"),
11798                AdfMark::underline(),
11799                AdfMark::annotation("ann-2", "inlineComment"),
11800            ],
11801            &["annotation", "underline", "annotation"],
11802        );
11803    }
11804
11805    #[test]
11806    fn round_trip_em_content_with_underscores() {
11807        // When em renders as `_..._` (to disambiguate from strong), any literal
11808        // underscores in the text must be escaped so they don't close the
11809        // emphasis span early.  Text like "foo_bar_baz" with [em, strong] must
11810        // survive round-trip with the underscores intact.
11811        let doc = AdfDocument {
11812            version: 1,
11813            doc_type: "doc".to_string(),
11814            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11815                "foo _bar_ baz",
11816                vec![AdfMark::em(), AdfMark::strong()],
11817            )])],
11818        };
11819        let md = adf_to_markdown(&doc).unwrap();
11820        let round_tripped = markdown_to_adf(&md).unwrap();
11821        let node = &round_tripped.content[0].content.as_ref().unwrap()[0];
11822        assert_eq!(node.text.as_deref(), Some("foo _bar_ baz"));
11823        let mark_types: Vec<&str> = node
11824            .marks
11825            .as_ref()
11826            .unwrap()
11827            .iter()
11828            .map(|m| m.mark_type.as_str())
11829            .collect();
11830        assert_eq!(mark_types, vec!["em", "strong"]);
11831    }
11832
11833    #[test]
11834    fn round_trip_link_nested_with_formatting_marks() {
11835        // Link may sit at any position in the marks array relative to em,
11836        // strong, strike, and underline — each position must round-trip.
11837        assert_mark_order_round_trip(
11838            vec![
11839                AdfMark::link("https://example.com"),
11840                AdfMark::strong(),
11841                AdfMark::em(),
11842            ],
11843            &["link", "strong", "em"],
11844        );
11845        assert_mark_order_round_trip(
11846            vec![
11847                AdfMark::em(),
11848                AdfMark::strong(),
11849                AdfMark::link("https://example.com"),
11850            ],
11851            &["em", "strong", "link"],
11852        );
11853        assert_mark_order_round_trip(
11854            vec![
11855                AdfMark::underline(),
11856                AdfMark::link("https://example.com"),
11857                AdfMark::strong(),
11858            ],
11859            &["underline", "link", "strong"],
11860        );
11861    }
11862
11863    /// Builds an `AdfMark` with the given type and no attrs, bypassing the
11864    /// usual constructors so we can exercise the defensive branches in the
11865    /// render helpers (the constructors always populate `attrs`).
11866    fn bare_mark(mark_type: &str) -> AdfMark {
11867        AdfMark {
11868            mark_type: mark_type.to_string(),
11869            attrs: None,
11870        }
11871    }
11872
11873    #[test]
11874    fn collect_span_attr_handles_missing_attrs() {
11875        // `textColor`/`backgroundColor`/`subsup` marks without the expected
11876        // `color`/`type` attr must not emit a fragment (the `if let` falls
11877        // through without pushing).  This exercises the inner-None branches
11878        // that the typed-constructor tests otherwise skip.
11879        let mut attrs = Vec::new();
11880        collect_span_attr(&bare_mark("textColor"), &mut attrs);
11881        collect_span_attr(&bare_mark("backgroundColor"), &mut attrs);
11882        collect_span_attr(&bare_mark("subsup"), &mut attrs);
11883        collect_span_attr(&bare_mark("link"), &mut attrs);
11884        assert!(attrs.is_empty(), "got: {attrs:?}");
11885    }
11886
11887    #[test]
11888    fn collect_bracketed_attr_handles_missing_attrs() {
11889        // An annotation mark with no attrs map at all must silently produce
11890        // no fragments — this covers the outer `if let Some(ref a)` None arm.
11891        let mut attrs = Vec::new();
11892        collect_bracketed_attr(&bare_mark("annotation"), &mut attrs);
11893        collect_bracketed_attr(&bare_mark("strong"), &mut attrs);
11894        assert!(attrs.is_empty(), "got: {attrs:?}");
11895    }
11896
11897    #[test]
11898    fn collect_bracketed_attr_handles_annotation_without_id() {
11899        // An annotation mark with attrs present but missing `id` and
11900        // `annotationType` keys still emits nothing — exercises the inner
11901        // None branches of each `if let` in the annotation arm.
11902        let mark = AdfMark {
11903            mark_type: "annotation".to_string(),
11904            attrs: Some(serde_json::json!({})),
11905        };
11906        let mut attrs = Vec::new();
11907        collect_bracketed_attr(&mark, &mut attrs);
11908        assert!(attrs.is_empty(), "got: {attrs:?}");
11909    }
11910
11911    #[test]
11912    fn span_attr_order_rejects_unknown_types() {
11913        // `span_attr_order` must classify unknown mark types as the sentinel
11914        // value, and `span_run_is_canonical` must reject a run that contains
11915        // any such unknown type.
11916        assert_eq!(span_attr_order("textColor"), 0);
11917        assert_eq!(span_attr_order("backgroundColor"), 1);
11918        assert_eq!(span_attr_order("subsup"), 2);
11919        assert_eq!(span_attr_order("strong"), u8::MAX);
11920        assert!(!span_run_is_canonical(&[bare_mark("strong")]));
11921    }
11922
11923    #[test]
11924    fn bracketed_run_rejects_unknown_types() {
11925        // `bracketed_run_is_canonical` only accepts `underline` and
11926        // `annotation`; any other mark type in the run short-circuits to
11927        // `false` so the caller emits nested wrappers.
11928        assert!(bracketed_run_is_canonical(&[
11929            AdfMark::underline(),
11930            AdfMark::annotation("x", "inlineComment")
11931        ]));
11932        assert!(!bracketed_run_is_canonical(&[
11933            AdfMark::annotation("x", "inlineComment"),
11934            AdfMark::underline()
11935        ]));
11936        assert!(!bracketed_run_is_canonical(&[bare_mark("strong")]));
11937    }
11938
11939    #[test]
11940    fn render_marked_text_ignores_unknown_mark_types() {
11941        // Unknown mark types fall through `render_marked_text`'s `_ =>`
11942        // arm and are dropped; the rendered JFM must still produce the
11943        // underlying text (and round-trip back to an unmarked text node).
11944        let doc = AdfDocument {
11945            version: 1,
11946            doc_type: "doc".to_string(),
11947            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
11948                "hello",
11949                vec![bare_mark("futureMark"), AdfMark::strong()],
11950            )])],
11951        };
11952        let md = adf_to_markdown(&doc).unwrap();
11953        assert_eq!(md.trim(), "**hello**");
11954        let rt = markdown_to_adf(&md).unwrap();
11955        let node = &rt.content[0].content.as_ref().unwrap()[0];
11956        assert_eq!(node.text.as_deref(), Some("hello"));
11957        let mark_types: Vec<&str> = node
11958            .marks
11959            .as_ref()
11960            .unwrap()
11961            .iter()
11962            .map(|m| m.mark_type.as_str())
11963            .collect();
11964        assert_eq!(mark_types, vec!["strong"]);
11965    }
11966
11967    #[test]
11968    fn triple_asterisk_parse_to_adf() {
11969        // Issue #401: ***text*** should parse as text with strong+em marks
11970        let md = "***bold and italic***\n";
11971        let doc = markdown_to_adf(md).unwrap();
11972        let node = &doc.content[0].content.as_ref().unwrap()[0];
11973        assert_eq!(node.text.as_deref(), Some("bold and italic"));
11974        let mark_types: Vec<&str> = node
11975            .marks
11976            .as_ref()
11977            .unwrap()
11978            .iter()
11979            .map(|m| m.mark_type.as_str())
11980            .collect();
11981        assert!(
11982            mark_types.contains(&"strong") && mark_types.contains(&"em"),
11983            "***text*** should produce both strong and em marks, got: {mark_types:?}"
11984        );
11985    }
11986
11987    #[test]
11988    fn triple_asterisk_with_surrounding_text() {
11989        // Issue #401: surrounding text should not be affected
11990        let md = "before ***bold italic*** after\n";
11991        let doc = markdown_to_adf(md).unwrap();
11992        let nodes = doc.content[0].content.as_ref().unwrap();
11993        // Should have: "before " (plain), "bold italic" (strong+em), " after" (plain)
11994        assert!(
11995            nodes.len() >= 3,
11996            "expected at least 3 nodes, got {}",
11997            nodes.len()
11998        );
11999        assert_eq!(nodes[0].text.as_deref(), Some("before "));
12000        assert_eq!(nodes[1].text.as_deref(), Some("bold italic"));
12001        let mark_types: Vec<&str> = nodes[1]
12002            .marks
12003            .as_ref()
12004            .unwrap()
12005            .iter()
12006            .map(|m| m.mark_type.as_str())
12007            .collect();
12008        assert!(
12009            mark_types.contains(&"strong") && mark_types.contains(&"em"),
12010            "middle node should have strong+em, got: {mark_types:?}"
12011        );
12012        assert_eq!(nodes[2].text.as_deref(), Some(" after"));
12013    }
12014
12015    #[test]
12016    fn annotation_mark_round_trip() {
12017        // Issue #364: annotation marks were silently dropped
12018        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12019          {"type":"text","text":"highlighted text","marks":[
12020            {"type":"annotation","attrs":{"id":"abc123","annotationType":"inlineComment"}}
12021          ]}
12022        ]}]}"#;
12023        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12024
12025        let md = adf_to_markdown(&doc).unwrap();
12026        assert!(
12027            md.contains("annotation-id="),
12028            "JFM should contain annotation-id, got: {md}"
12029        );
12030
12031        let round_tripped = markdown_to_adf(&md).unwrap();
12032        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12033        assert_eq!(text_node.text.as_deref(), Some("highlighted text"));
12034        let marks = text_node.marks.as_ref().expect("should have marks");
12035        let ann = marks
12036            .iter()
12037            .find(|m| m.mark_type == "annotation")
12038            .expect("should have annotation mark");
12039        let attrs = ann.attrs.as_ref().unwrap();
12040        assert_eq!(attrs["id"], "abc123");
12041        assert_eq!(attrs["annotationType"], "inlineComment");
12042    }
12043
12044    #[test]
12045    fn annotation_mark_with_bold() {
12046        // Annotation + bold should both survive round-trip
12047        let doc = AdfDocument {
12048            version: 1,
12049            doc_type: "doc".to_string(),
12050            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12051                "bold comment",
12052                vec![
12053                    AdfMark::strong(),
12054                    AdfMark::annotation("def456", "inlineComment"),
12055                ],
12056            )])],
12057        };
12058        let md = adf_to_markdown(&doc).unwrap();
12059        let round_tripped = markdown_to_adf(&md).unwrap();
12060        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12061        let marks = text_node.marks.as_ref().expect("should have marks");
12062        assert!(
12063            marks.iter().any(|m| m.mark_type == "strong"),
12064            "should have strong mark"
12065        );
12066        assert!(
12067            marks.iter().any(|m| m.mark_type == "annotation"),
12068            "should have annotation mark"
12069        );
12070    }
12071
12072    #[test]
12073    fn annotation_and_link_marks_both_preserved() {
12074        // Issue #390: text with both annotation and link marks loses link on round-trip
12075        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12076          {"type":"text","text":"HANGUL-8","marks":[
12077            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"5ca7425e-34cd-48d3-b4eb-9873ac8b20e0"}},
12078            {"type":"link","attrs":{"href":"https://zd.atlassian.net/browse/HANG-8"}}
12079          ]}
12080        ]}]}"#;
12081        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12082        let md = adf_to_markdown(&doc).unwrap();
12083        // Should contain both annotation attrs and link syntax
12084        assert!(
12085            md.contains("annotation-id="),
12086            "JFM should contain annotation-id, got: {md}"
12087        );
12088        assert!(
12089            md.contains("](https://"),
12090            "JFM should contain link href, got: {md}"
12091        );
12092        let round_tripped = markdown_to_adf(&md).unwrap();
12093        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12094        let marks = text_node.marks.as_ref().expect("should have marks");
12095        assert!(
12096            marks.iter().any(|m| m.mark_type == "annotation"),
12097            "should have annotation mark, got: {:?}",
12098            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12099        );
12100        assert!(
12101            marks.iter().any(|m| m.mark_type == "link"),
12102            "should have link mark, got: {:?}",
12103            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12104        );
12105    }
12106
12107    #[test]
12108    fn annotation_and_code_marks_both_preserved() {
12109        // Issue #508: annotation mark dropped when combined with code mark
12110        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12111          {"type":"text","text":"some text with "},
12112          {"type":"text","text":"annotated code","marks":[
12113            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"aabbccdd-1234-5678-abcd-000000000001"}},
12114            {"type":"code"}
12115          ]},
12116          {"type":"text","text":" remaining text"}
12117        ]}]}"#;
12118        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12119        let md = adf_to_markdown(&doc).unwrap();
12120        assert!(
12121            md.contains("annotation-id="),
12122            "JFM should contain annotation-id, got: {md}"
12123        );
12124        assert!(
12125            md.contains('`'),
12126            "JFM should contain backticks for code, got: {md}"
12127        );
12128
12129        let round_tripped = markdown_to_adf(&md).unwrap();
12130        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12131        // Find the text node with "annotated code"
12132        let code_node = nodes
12133            .iter()
12134            .find(|n| n.text.as_deref() == Some("annotated code"))
12135            .expect("should have 'annotated code' text node");
12136        let marks = code_node.marks.as_ref().expect("should have marks");
12137        assert!(
12138            marks.iter().any(|m| m.mark_type == "annotation"),
12139            "should have annotation mark, got: {:?}",
12140            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12141        );
12142        assert!(
12143            marks.iter().any(|m| m.mark_type == "code"),
12144            "should have code mark, got: {:?}",
12145            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12146        );
12147        let ann = marks.iter().find(|m| m.mark_type == "annotation").unwrap();
12148        let attrs = ann.attrs.as_ref().unwrap();
12149        assert_eq!(attrs["id"], "aabbccdd-1234-5678-abcd-000000000001");
12150        assert_eq!(attrs["annotationType"], "inlineComment");
12151    }
12152
12153    #[test]
12154    fn annotation_and_code_and_link_marks_all_preserved() {
12155        // annotation + code + link should all survive round-trip
12156        let doc = AdfDocument {
12157            version: 1,
12158            doc_type: "doc".to_string(),
12159            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12160                "linked code",
12161                vec![
12162                    AdfMark::annotation("ann-001", "inlineComment"),
12163                    AdfMark::code(),
12164                    AdfMark::link("https://example.com"),
12165                ],
12166            )])],
12167        };
12168        let md = adf_to_markdown(&doc).unwrap();
12169        assert!(
12170            md.contains("annotation-id="),
12171            "JFM should contain annotation-id, got: {md}"
12172        );
12173        assert!(md.contains('`'), "JFM should contain backticks, got: {md}");
12174        assert!(
12175            md.contains("](https://example.com)"),
12176            "JFM should contain link, got: {md}"
12177        );
12178
12179        let round_tripped = markdown_to_adf(&md).unwrap();
12180        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12181        let marks = text_node.marks.as_ref().expect("should have marks");
12182        assert!(
12183            marks.iter().any(|m| m.mark_type == "annotation"),
12184            "should have annotation mark, got: {:?}",
12185            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12186        );
12187        assert!(
12188            marks.iter().any(|m| m.mark_type == "code"),
12189            "should have code mark, got: {:?}",
12190            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12191        );
12192        assert!(
12193            marks.iter().any(|m| m.mark_type == "link"),
12194            "should have link mark, got: {:?}",
12195            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
12196        );
12197    }
12198
12199    #[test]
12200    fn multiple_annotations_and_code_mark_preserved() {
12201        // Multiple annotation marks on a code node should all survive
12202        let doc = AdfDocument {
12203            version: 1,
12204            doc_type: "doc".to_string(),
12205            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12206                "doubly annotated",
12207                vec![
12208                    AdfMark::annotation("ann-aaa", "inlineComment"),
12209                    AdfMark::annotation("ann-bbb", "inlineComment"),
12210                    AdfMark::code(),
12211                ],
12212            )])],
12213        };
12214        let md = adf_to_markdown(&doc).unwrap();
12215        assert!(
12216            md.contains("ann-aaa"),
12217            "JFM should contain first annotation id, got: {md}"
12218        );
12219        assert!(
12220            md.contains("ann-bbb"),
12221            "JFM should contain second annotation id, got: {md}"
12222        );
12223
12224        let round_tripped = markdown_to_adf(&md).unwrap();
12225        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12226        let marks = text_node.marks.as_ref().expect("should have marks");
12227        let ann_marks: Vec<_> = marks
12228            .iter()
12229            .filter(|m| m.mark_type == "annotation")
12230            .collect();
12231        assert_eq!(
12232            ann_marks.len(),
12233            2,
12234            "should have 2 annotation marks, got: {}",
12235            ann_marks.len()
12236        );
12237        assert!(
12238            marks.iter().any(|m| m.mark_type == "code"),
12239            "should have code mark"
12240        );
12241    }
12242
12243    #[test]
12244    fn underline_and_link_marks_both_preserved() {
12245        // Underline + link should also coexist
12246        let doc = AdfDocument {
12247            version: 1,
12248            doc_type: "doc".to_string(),
12249            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12250                "click here",
12251                vec![AdfMark::underline(), AdfMark::link("https://example.com")],
12252            )])],
12253        };
12254        let md = adf_to_markdown(&doc).unwrap();
12255        assert!(md.contains("underline"), "should have underline attr: {md}");
12256        assert!(
12257            md.contains("](https://example.com)"),
12258            "should have link: {md}"
12259        );
12260        let round_tripped = markdown_to_adf(&md).unwrap();
12261        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12262        let marks = text_node.marks.as_ref().expect("should have marks");
12263        assert!(marks.iter().any(|m| m.mark_type == "underline"));
12264        assert!(marks.iter().any(|m| m.mark_type == "link"));
12265    }
12266
12267    #[test]
12268    fn annotation_link_and_bold_all_preserved() {
12269        // All three marks should coexist
12270        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12271          {"type":"text","text":"important","marks":[
12272            {"type":"annotation","attrs":{"annotationType":"inlineComment","id":"abc"}},
12273            {"type":"link","attrs":{"href":"https://example.com"}},
12274            {"type":"strong"}
12275          ]}
12276        ]}]}"#;
12277        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12278        let md = adf_to_markdown(&doc).unwrap();
12279        let round_tripped = markdown_to_adf(&md).unwrap();
12280        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12281        let marks = text_node.marks.as_ref().expect("should have marks");
12282        assert!(
12283            marks.iter().any(|m| m.mark_type == "annotation"),
12284            "should have annotation"
12285        );
12286        assert!(
12287            marks.iter().any(|m| m.mark_type == "link"),
12288            "should have link"
12289        );
12290        assert!(
12291            marks.iter().any(|m| m.mark_type == "strong"),
12292            "should have strong"
12293        );
12294    }
12295
12296    #[test]
12297    fn multiple_annotation_marks_round_trip() {
12298        // Issue #439: multiple annotation marks on same text node — all but last dropped
12299        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12300          {"type":"text","text":"some annotated text","marks":[
12301            {"type":"annotation","attrs":{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","annotationType":"inlineComment"}},
12302            {"type":"annotation","attrs":{"id":"ffffffff-1111-2222-3333-444444444444","annotationType":"inlineComment"}}
12303          ]}
12304        ]}]}"#;
12305        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12306
12307        let md = adf_to_markdown(&doc).unwrap();
12308        assert!(
12309            md.contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
12310            "JFM should contain first annotation id, got: {md}"
12311        );
12312        assert!(
12313            md.contains("ffffffff-1111-2222-3333-444444444444"),
12314            "JFM should contain second annotation id, got: {md}"
12315        );
12316
12317        let round_tripped = markdown_to_adf(&md).unwrap();
12318        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12319        assert_eq!(text_node.text.as_deref(), Some("some annotated text"));
12320        let marks = text_node.marks.as_ref().expect("should have marks");
12321        let annotations: Vec<_> = marks
12322            .iter()
12323            .filter(|m| m.mark_type == "annotation")
12324            .collect();
12325        assert_eq!(
12326            annotations.len(),
12327            2,
12328            "should have 2 annotation marks, got: {annotations:?}"
12329        );
12330        let ids: Vec<_> = annotations
12331            .iter()
12332            .map(|a| a.attrs.as_ref().unwrap()["id"].as_str().unwrap())
12333            .collect();
12334        assert!(ids.contains(&"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"));
12335        assert!(ids.contains(&"ffffffff-1111-2222-3333-444444444444"));
12336    }
12337
12338    #[test]
12339    fn three_annotation_marks_round_trip() {
12340        // Verify three overlapping annotations all survive
12341        let doc = AdfDocument {
12342            version: 1,
12343            doc_type: "doc".to_string(),
12344            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12345                "triple annotated",
12346                vec![
12347                    AdfMark::annotation("id-1", "inlineComment"),
12348                    AdfMark::annotation("id-2", "inlineComment"),
12349                    AdfMark::annotation("id-3", "inlineComment"),
12350                ],
12351            )])],
12352        };
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        let annotations: Vec<_> = marks
12358            .iter()
12359            .filter(|m| m.mark_type == "annotation")
12360            .collect();
12361        assert_eq!(
12362            annotations.len(),
12363            3,
12364            "should have 3 annotation marks, got: {annotations:?}"
12365        );
12366    }
12367
12368    #[test]
12369    fn multiple_annotations_with_bold_round_trip() {
12370        // Multiple annotations + bold should all survive
12371        let doc = AdfDocument {
12372            version: 1,
12373            doc_type: "doc".to_string(),
12374            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
12375                "bold double annotated",
12376                vec![
12377                    AdfMark::strong(),
12378                    AdfMark::annotation("ann-a", "inlineComment"),
12379                    AdfMark::annotation("ann-b", "inlineComment"),
12380                ],
12381            )])],
12382        };
12383        let md = adf_to_markdown(&doc).unwrap();
12384        let round_tripped = markdown_to_adf(&md).unwrap();
12385        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12386        let marks = text_node.marks.as_ref().expect("should have marks");
12387        assert!(
12388            marks.iter().any(|m| m.mark_type == "strong"),
12389            "should have strong mark"
12390        );
12391        let annotations: Vec<_> = marks
12392            .iter()
12393            .filter(|m| m.mark_type == "annotation")
12394            .collect();
12395        assert_eq!(
12396            annotations.len(),
12397            2,
12398            "should have 2 annotation marks, got: {annotations:?}"
12399        );
12400    }
12401
12402    #[test]
12403    fn multiple_annotations_with_link_round_trip() {
12404        // Multiple annotations + link should all survive
12405        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12406          {"type":"text","text":"linked text","marks":[
12407            {"type":"annotation","attrs":{"id":"ann-x","annotationType":"inlineComment"}},
12408            {"type":"annotation","attrs":{"id":"ann-y","annotationType":"inlineComment"}},
12409            {"type":"link","attrs":{"href":"https://example.com"}}
12410          ]}
12411        ]}]}"#;
12412        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12413        let md = adf_to_markdown(&doc).unwrap();
12414        let round_tripped = markdown_to_adf(&md).unwrap();
12415        let text_node = &round_tripped.content[0].content.as_ref().unwrap()[0];
12416        let marks = text_node.marks.as_ref().expect("should have marks");
12417        assert!(
12418            marks.iter().any(|m| m.mark_type == "link"),
12419            "should have link mark"
12420        );
12421        let annotations: Vec<_> = marks
12422            .iter()
12423            .filter(|m| m.mark_type == "annotation")
12424            .collect();
12425        assert_eq!(
12426            annotations.len(),
12427            2,
12428            "should have 2 annotation marks, got: {annotations:?}"
12429        );
12430    }
12431
12432    // ── Issue #471: annotation marks on non-text inline nodes ─────────
12433
12434    #[test]
12435    fn annotation_on_emoji_round_trip() {
12436        // Issue #471: annotation mark on emoji node should survive round-trip
12437        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12438          {"type":"emoji","attrs":{"id":"1f4dd","shortName":":memo:","text":"📝"},"marks":[
12439            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12440          ]},
12441          {"type":"text","text":" annotated text","marks":[
12442            {"type":"annotation","attrs":{"id":"ccddee11-2233-4455-aabb-ccddee112233","annotationType":"inlineComment"}}
12443          ]}
12444        ]}]}"#;
12445        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12446        let md = adf_to_markdown(&doc).unwrap();
12447        assert!(
12448            md.contains("annotation-id="),
12449            "JFM should contain annotation-id for emoji, got: {md}"
12450        );
12451
12452        let round_tripped = markdown_to_adf(&md).unwrap();
12453        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12454
12455        // Emoji node should retain annotation mark
12456        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12457        let emoji_marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12458        assert!(
12459            emoji_marks.iter().any(|m| m.mark_type == "annotation"),
12460            "emoji should have annotation mark, got: {emoji_marks:?}"
12461        );
12462        let ann = emoji_marks
12463            .iter()
12464            .find(|m| m.mark_type == "annotation")
12465            .unwrap();
12466        assert_eq!(
12467            ann.attrs.as_ref().unwrap()["id"],
12468            "ccddee11-2233-4455-aabb-ccddee112233"
12469        );
12470
12471        // Text node should also retain annotation mark
12472        let text_node = nodes.iter().find(|n| n.node_type == "text").unwrap();
12473        let text_marks = text_node.marks.as_ref().expect("text should have marks");
12474        assert!(
12475            text_marks.iter().any(|m| m.mark_type == "annotation"),
12476            "text should have annotation mark"
12477        );
12478    }
12479
12480    #[test]
12481    fn annotation_on_status_round_trip() {
12482        let mut status = AdfNode::status("In Progress", "blue");
12483        status.marks = Some(vec![AdfMark::annotation("ann-status-1", "inlineComment")]);
12484
12485        let doc = AdfDocument {
12486            version: 1,
12487            doc_type: "doc".to_string(),
12488            content: vec![AdfNode::paragraph(vec![status])],
12489        };
12490        let md = adf_to_markdown(&doc).unwrap();
12491        assert!(
12492            md.contains("annotation-id="),
12493            "JFM should contain annotation-id for status, got: {md}"
12494        );
12495
12496        let round_tripped = markdown_to_adf(&md).unwrap();
12497        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12498        let status_node = nodes.iter().find(|n| n.node_type == "status").unwrap();
12499        let marks = status_node
12500            .marks
12501            .as_ref()
12502            .expect("status should have marks");
12503        assert!(
12504            marks.iter().any(|m| m.mark_type == "annotation"),
12505            "status should have annotation mark, got: {marks:?}"
12506        );
12507    }
12508
12509    #[test]
12510    fn annotation_on_date_round_trip() {
12511        let mut date = AdfNode::date("1704067200000");
12512        date.marks = Some(vec![AdfMark::annotation("ann-date-1", "inlineComment")]);
12513
12514        let doc = AdfDocument {
12515            version: 1,
12516            doc_type: "doc".to_string(),
12517            content: vec![AdfNode::paragraph(vec![date])],
12518        };
12519        let md = adf_to_markdown(&doc).unwrap();
12520        assert!(
12521            md.contains("annotation-id="),
12522            "JFM should contain annotation-id for date, got: {md}"
12523        );
12524
12525        let round_tripped = markdown_to_adf(&md).unwrap();
12526        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12527        let date_node = nodes.iter().find(|n| n.node_type == "date").unwrap();
12528        let marks = date_node.marks.as_ref().expect("date should have marks");
12529        assert!(
12530            marks.iter().any(|m| m.mark_type == "annotation"),
12531            "date should have annotation mark, got: {marks:?}"
12532        );
12533    }
12534
12535    #[test]
12536    fn annotation_on_mention_round_trip() {
12537        let mut mention = AdfNode::mention("user-123", "@Alice");
12538        mention.marks = Some(vec![AdfMark::annotation("ann-mention-1", "inlineComment")]);
12539
12540        let doc = AdfDocument {
12541            version: 1,
12542            doc_type: "doc".to_string(),
12543            content: vec![AdfNode::paragraph(vec![mention])],
12544        };
12545        let md = adf_to_markdown(&doc).unwrap();
12546        assert!(
12547            md.contains("annotation-id="),
12548            "JFM should contain annotation-id for mention, got: {md}"
12549        );
12550
12551        let round_tripped = markdown_to_adf(&md).unwrap();
12552        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12553        let mention_node = nodes.iter().find(|n| n.node_type == "mention").unwrap();
12554        let marks = mention_node
12555            .marks
12556            .as_ref()
12557            .expect("mention should have marks");
12558        assert!(
12559            marks.iter().any(|m| m.mark_type == "annotation"),
12560            "mention should have annotation mark, got: {marks:?}"
12561        );
12562    }
12563
12564    #[test]
12565    fn annotation_on_inline_card_round_trip() {
12566        let mut card = AdfNode::inline_card("https://example.com");
12567        card.marks = Some(vec![AdfMark::annotation("ann-card-1", "inlineComment")]);
12568
12569        let doc = AdfDocument {
12570            version: 1,
12571            doc_type: "doc".to_string(),
12572            content: vec![AdfNode::paragraph(vec![card])],
12573        };
12574        let md = adf_to_markdown(&doc).unwrap();
12575        assert!(
12576            md.contains("annotation-id="),
12577            "JFM should contain annotation-id for inlineCard, got: {md}"
12578        );
12579
12580        let round_tripped = markdown_to_adf(&md).unwrap();
12581        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12582        let card_node = nodes.iter().find(|n| n.node_type == "inlineCard").unwrap();
12583        let marks = card_node
12584            .marks
12585            .as_ref()
12586            .expect("inlineCard should have marks");
12587        assert!(
12588            marks.iter().any(|m| m.mark_type == "annotation"),
12589            "inlineCard should have annotation mark, got: {marks:?}"
12590        );
12591    }
12592
12593    #[test]
12594    fn annotation_on_placeholder_round_trip() {
12595        let mut placeholder = AdfNode::placeholder("Enter text here");
12596        placeholder.marks = Some(vec![AdfMark::annotation("ann-ph-1", "inlineComment")]);
12597
12598        let doc = AdfDocument {
12599            version: 1,
12600            doc_type: "doc".to_string(),
12601            content: vec![AdfNode::paragraph(vec![placeholder])],
12602        };
12603        let md = adf_to_markdown(&doc).unwrap();
12604        assert!(
12605            md.contains("annotation-id="),
12606            "JFM should contain annotation-id for placeholder, got: {md}"
12607        );
12608
12609        let round_tripped = markdown_to_adf(&md).unwrap();
12610        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12611        let ph_node = nodes.iter().find(|n| n.node_type == "placeholder").unwrap();
12612        let marks = ph_node
12613            .marks
12614            .as_ref()
12615            .expect("placeholder should have marks");
12616        assert!(
12617            marks.iter().any(|m| m.mark_type == "annotation"),
12618            "placeholder should have annotation mark, got: {marks:?}"
12619        );
12620    }
12621
12622    #[test]
12623    fn multiple_annotations_on_emoji_round_trip() {
12624        // Multiple annotation marks on a single emoji node
12625        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
12626          {"type":"emoji","attrs":{"shortName":":fire:","text":"🔥"},"marks":[
12627            {"type":"annotation","attrs":{"id":"ann-1","annotationType":"inlineComment"}},
12628            {"type":"annotation","attrs":{"id":"ann-2","annotationType":"inlineComment"}}
12629          ]}
12630        ]}]}"#;
12631        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12632        let md = adf_to_markdown(&doc).unwrap();
12633
12634        let round_tripped = markdown_to_adf(&md).unwrap();
12635        let nodes = round_tripped.content[0].content.as_ref().unwrap();
12636        let emoji_node = nodes.iter().find(|n| n.node_type == "emoji").unwrap();
12637        let marks = emoji_node.marks.as_ref().expect("emoji should have marks");
12638        let annotations: Vec<_> = marks
12639            .iter()
12640            .filter(|m| m.mark_type == "annotation")
12641            .collect();
12642        assert_eq!(
12643            annotations.len(),
12644            2,
12645            "emoji should have 2 annotation marks, got: {annotations:?}"
12646        );
12647    }
12648
12649    #[test]
12650    fn emoji_without_annotation_unchanged() {
12651        // Ensure emoji nodes without annotation marks are not affected
12652        let doc = AdfDocument {
12653            version: 1,
12654            doc_type: "doc".to_string(),
12655            content: vec![AdfNode::paragraph(vec![AdfNode::emoji(":fire:")])],
12656        };
12657        let md = adf_to_markdown(&doc).unwrap();
12658        // Should NOT have bracketed span wrapping
12659        assert!(
12660            !md.contains('['),
12661            "emoji without annotation should not be wrapped in brackets, got: {md}"
12662        );
12663        assert!(md.contains(":fire:"));
12664    }
12665
12666    // ── Inline directive tests (Tier 4) ───────────────────────────────
12667
12668    #[test]
12669    fn status_directive() {
12670        let doc = markdown_to_adf("The ticket is :status[In Progress]{color=blue}.").unwrap();
12671        let content = doc.content[0].content.as_ref().unwrap();
12672        assert_eq!(content[1].node_type, "status");
12673        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "In Progress");
12674        assert_eq!(content[1].attrs.as_ref().unwrap()["color"], "blue");
12675    }
12676
12677    #[test]
12678    fn adf_status_to_markdown() {
12679        let doc = AdfDocument {
12680            version: 1,
12681            doc_type: "doc".to_string(),
12682            content: vec![AdfNode::paragraph(vec![AdfNode::status("Done", "green")])],
12683        };
12684        let md = adf_to_markdown(&doc).unwrap();
12685        assert!(md.contains(":status[Done]{color=green}"));
12686    }
12687
12688    #[test]
12689    fn round_trip_status() {
12690        let md = "The ticket is :status[In Progress]{color=blue}.\n";
12691        let doc = markdown_to_adf(md).unwrap();
12692        let result = adf_to_markdown(&doc).unwrap();
12693        assert!(result.contains(":status[In Progress]{color=blue}"));
12694    }
12695
12696    #[test]
12697    fn status_with_style_and_localid_roundtrips() {
12698        let adf = AdfDocument {
12699            version: 1,
12700            doc_type: "doc".to_string(),
12701            content: vec![AdfNode::paragraph(vec![{
12702                let mut node = AdfNode::status("open", "green");
12703                node.attrs.as_mut().unwrap()["style"] =
12704                    serde_json::Value::String("bold".to_string());
12705                node.attrs.as_mut().unwrap()["localId"] =
12706                    serde_json::Value::String("d2205ca5-84b9-4950-a730-bfe550fc146b".to_string());
12707                node
12708            }])],
12709        };
12710
12711        let md = adf_to_markdown(&adf).unwrap();
12712        assert!(
12713            md.contains("style=bold"),
12714            "Markdown should contain style attr: {md}"
12715        );
12716        assert!(
12717            md.contains("localId=d2205ca5"),
12718            "Markdown should contain localId attr: {md}"
12719        );
12720
12721        let rt = markdown_to_adf(&md).unwrap();
12722        let status = &rt.content[0].content.as_ref().unwrap()[0];
12723        let attrs = status.attrs.as_ref().unwrap();
12724        assert_eq!(attrs["text"], "open");
12725        assert_eq!(attrs["color"], "green");
12726        assert_eq!(attrs["style"], "bold");
12727        assert_eq!(
12728            attrs["localId"], "d2205ca5-84b9-4950-a730-bfe550fc146b",
12729            "localId should be preserved, got: {}",
12730            attrs["localId"]
12731        );
12732    }
12733
12734    #[test]
12735    fn status_without_style_still_works() {
12736        let md = ":status[Done]{color=green}\n";
12737        let doc = markdown_to_adf(md).unwrap();
12738        let status = &doc.content[0].content.as_ref().unwrap()[0];
12739        let attrs = status.attrs.as_ref().unwrap();
12740        assert_eq!(attrs["text"], "Done");
12741        assert_eq!(attrs["color"], "green");
12742        // No style attr — should not be present
12743        assert!(
12744            attrs.get("style").is_none() || attrs["style"].is_null(),
12745            "style should not be set when not provided"
12746        );
12747    }
12748
12749    #[test]
12750    fn strip_local_ids_removes_localid_from_status() {
12751        let adf = AdfDocument {
12752            version: 1,
12753            doc_type: "doc".to_string(),
12754            content: vec![AdfNode::paragraph(vec![{
12755                let mut node = AdfNode::status("open", "green");
12756                node.attrs.as_mut().unwrap()["localId"] =
12757                    serde_json::Value::String("real-uuid-here".to_string());
12758                node
12759            }])],
12760        };
12761        let opts = RenderOptions {
12762            strip_local_ids: true,
12763        };
12764        let md = adf_to_markdown_with_options(&adf, &opts).unwrap();
12765        assert!(
12766            !md.contains("localId"),
12767            "localId should be stripped, got: {md}"
12768        );
12769        assert!(md.contains("color=green"), "color should be preserved");
12770    }
12771
12772    #[test]
12773    fn strip_local_ids_removes_localid_from_table() {
12774        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"}]}]}]}]}]}"#;
12775        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12776        let opts = RenderOptions {
12777            strip_local_ids: true,
12778        };
12779        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12780        assert!(
12781            !md.contains("localId"),
12782            "localId should be stripped from table, got: {md}"
12783        );
12784        assert!(md.contains("layout=default"), "layout should be preserved");
12785    }
12786
12787    #[test]
12788    fn default_options_preserve_localid() {
12789        let adf = AdfDocument {
12790            version: 1,
12791            doc_type: "doc".to_string(),
12792            content: vec![AdfNode::paragraph(vec![{
12793                let mut node = AdfNode::status("open", "green");
12794                node.attrs.as_mut().unwrap()["localId"] =
12795                    serde_json::Value::String("real-uuid-here".to_string());
12796                node
12797            }])],
12798        };
12799        let md = adf_to_markdown(&adf).unwrap();
12800        assert!(
12801            md.contains("localId=real-uuid-here"),
12802            "Default should preserve localId, got: {md}"
12803        );
12804    }
12805
12806    #[test]
12807    fn mention_localid_roundtrip() {
12808        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12809        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12810        let md = adf_to_markdown(&doc).unwrap();
12811        assert!(
12812            md.contains("localId=m-001"),
12813            "mention should have localId in md: {md}"
12814        );
12815        let rt = markdown_to_adf(&md).unwrap();
12816        let mention = &rt.content[0].content.as_ref().unwrap()[0];
12817        assert_eq!(mention.attrs.as_ref().unwrap()["localId"], "m-001");
12818    }
12819
12820    #[test]
12821    fn date_localid_roundtrip() {
12822        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12823        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12824        let md = adf_to_markdown(&doc).unwrap();
12825        assert!(
12826            md.contains("localId=d-001"),
12827            "date should have localId in md: {md}"
12828        );
12829        let rt = markdown_to_adf(&md).unwrap();
12830        let date = &rt.content[0].content.as_ref().unwrap()[0];
12831        assert_eq!(date.attrs.as_ref().unwrap()["localId"], "d-001");
12832    }
12833
12834    #[test]
12835    fn emoji_localid_roundtrip() {
12836        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"emoji","attrs":{"shortName":":smile:","localId":"e-001"}}]}]}"#;
12837        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12838        let md = adf_to_markdown(&doc).unwrap();
12839        assert!(
12840            md.contains("localId=e-001"),
12841            "emoji should have localId in md: {md}"
12842        );
12843        let rt = markdown_to_adf(&md).unwrap();
12844        let emoji = &rt.content[0].content.as_ref().unwrap()[0];
12845        assert_eq!(emoji.attrs.as_ref().unwrap()["localId"], "e-001");
12846    }
12847
12848    #[test]
12849    fn inline_card_localid_roundtrip() {
12850        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"inlineCard","attrs":{"url":"https://example.com","localId":"c-001"}}]}]}"#;
12851        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12852        let md = adf_to_markdown(&doc).unwrap();
12853        assert!(
12854            md.contains("localId=c-001"),
12855            "inlineCard should have localId in md: {md}"
12856        );
12857        let rt = markdown_to_adf(&md).unwrap();
12858        let card = &rt.content[0].content.as_ref().unwrap()[0];
12859        assert_eq!(card.attrs.as_ref().unwrap()["localId"], "c-001");
12860    }
12861
12862    #[test]
12863    fn strip_local_ids_removes_from_mention() {
12864        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"mention","attrs":{"id":"user123","text":"@Alice","localId":"m-001"}}]}]}"#;
12865        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12866        let opts = RenderOptions {
12867            strip_local_ids: true,
12868        };
12869        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12870        assert!(
12871            !md.contains("localId"),
12872            "localId should be stripped from mention: {md}"
12873        );
12874        assert!(md.contains("id=user123"), "other attrs should be preserved");
12875    }
12876
12877    #[test]
12878    fn strip_local_ids_removes_from_date() {
12879        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
12880        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12881        let opts = RenderOptions {
12882            strip_local_ids: true,
12883        };
12884        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12885        assert!(
12886            !md.contains("localId"),
12887            "localId should be stripped from date: {md}"
12888        );
12889    }
12890
12891    #[test]
12892    fn strip_local_ids_removes_from_block_attrs() {
12893        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"p-001"},"content":[{"type":"text","text":"hello"}]}]}"#;
12894        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12895        let opts = RenderOptions {
12896            strip_local_ids: true,
12897        };
12898        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
12899        assert!(
12900            !md.contains("localId"),
12901            "localId should be stripped from block attrs: {md}"
12902        );
12903    }
12904
12905    #[test]
12906    fn table_cell_localid_roundtrip() {
12907        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"}]}]}]}]}]}"#;
12908        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12909        let md = adf_to_markdown(&doc).unwrap();
12910        assert!(
12911            md.contains("localId=tc-001"),
12912            "tableCell should have localId in md: {md}"
12913        );
12914        let rt = markdown_to_adf(&md).unwrap();
12915        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12916            .content
12917            .as_ref()
12918            .unwrap()[0];
12919        assert_eq!(
12920            cell.attrs.as_ref().unwrap()["localId"],
12921            "tc-001",
12922            "tableCell localId should round-trip"
12923        );
12924    }
12925
12926    #[test]
12927    fn table_cell_border_mark_roundtrip() {
12928        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"}]}]}]}]}]}"##;
12929        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12930        let md = adf_to_markdown(&doc).unwrap();
12931        assert!(
12932            md.contains("border-color=#ff000033"),
12933            "tableCell should have border-color in md: {md}"
12934        );
12935        assert!(
12936            md.contains("border-size=2"),
12937            "tableCell should have border-size in md: {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        let marks = cell.marks.as_ref().expect("tableCell should have marks");
12945        assert_eq!(marks.len(), 1);
12946        assert_eq!(marks[0].mark_type, "border");
12947        let attrs = marks[0].attrs.as_ref().unwrap();
12948        assert_eq!(attrs["color"], "#ff000033");
12949        assert_eq!(attrs["size"], 2);
12950    }
12951
12952    #[test]
12953    fn table_header_border_mark_roundtrip() {
12954        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"}]}]}]}]}]}"##;
12955        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12956        let md = adf_to_markdown(&doc).unwrap();
12957        assert!(md.contains("border-color=#0000ff"), "md: {md}");
12958        assert!(md.contains("border-size=3"), "md: {md}");
12959        let rt = markdown_to_adf(&md).unwrap();
12960        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12961            .content
12962            .as_ref()
12963            .unwrap()[0];
12964        assert_eq!(cell.node_type, "tableHeader");
12965        let marks = cell.marks.as_ref().expect("tableHeader should have marks");
12966        assert_eq!(marks[0].mark_type, "border");
12967        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#0000ff");
12968        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
12969    }
12970
12971    #[test]
12972    fn table_cell_border_mark_with_attrs_roundtrip() {
12973        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"}]}]}]}]}]}"##;
12974        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12975        let md = adf_to_markdown(&doc).unwrap();
12976        assert!(md.contains("bg=#e6fcff"), "md: {md}");
12977        assert!(md.contains("colspan=2"), "md: {md}");
12978        assert!(md.contains("border-color=#ff000033"), "md: {md}");
12979        let rt = markdown_to_adf(&md).unwrap();
12980        let cell = &rt.content[0].content.as_ref().unwrap()[0]
12981            .content
12982            .as_ref()
12983            .unwrap()[0];
12984        assert_eq!(cell.attrs.as_ref().unwrap()["background"], "#e6fcff");
12985        assert_eq!(cell.attrs.as_ref().unwrap()["colspan"], 2);
12986        let marks = cell.marks.as_ref().expect("should have marks");
12987        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff000033");
12988    }
12989
12990    #[test]
12991    fn table_cell_no_border_mark_unchanged() {
12992        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"}]}]}]}]}]}"#;
12993        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
12994        let md = adf_to_markdown(&doc).unwrap();
12995        assert!(
12996            !md.contains("border-color"),
12997            "no border attrs expected: {md}"
12998        );
12999        let rt = markdown_to_adf(&md).unwrap();
13000        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13001            .content
13002            .as_ref()
13003            .unwrap()[0];
13004        assert!(cell.marks.is_none(), "no marks expected on plain cell");
13005    }
13006
13007    #[test]
13008    fn table_cell_border_size_only_defaults_color() {
13009        // border-size without border-color should still produce a border mark
13010        // with the default color
13011        let md = "::::table\n:::tr\n:::td{border-size=3}\ncell\n:::\n:::\n::::\n";
13012        let doc = markdown_to_adf(md).unwrap();
13013        let cell = &doc.content[0].content.as_ref().unwrap()[0]
13014            .content
13015            .as_ref()
13016            .unwrap()[0];
13017        let marks = cell.marks.as_ref().expect("should have border mark");
13018        assert_eq!(marks[0].mark_type, "border");
13019        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13020        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13021    }
13022
13023    #[test]
13024    fn table_cell_border_color_only_defaults_size() {
13025        // border-color without border-size should default size to 1
13026        let md = "::::table\n:::tr\n:::td{border-color=#ff0000}\ncell\n:::\n:::\n::::\n";
13027        let doc = markdown_to_adf(md).unwrap();
13028        let cell = &doc.content[0].content.as_ref().unwrap()[0]
13029            .content
13030            .as_ref()
13031            .unwrap()[0];
13032        let marks = cell.marks.as_ref().expect("should have border mark");
13033        assert_eq!(marks[0].mark_type, "border");
13034        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13035        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13036    }
13037
13038    #[test]
13039    fn media_file_border_mark_roundtrip() {
13040        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}}]}]}]}"##;
13041        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13042        let md = adf_to_markdown(&doc).unwrap();
13043        assert!(
13044            md.contains("border-color=#091e4224"),
13045            "media should have border-color in md: {md}"
13046        );
13047        assert!(
13048            md.contains("border-size=2"),
13049            "media should have border-size in md: {md}"
13050        );
13051        let rt = markdown_to_adf(&md).unwrap();
13052        let media_single = &rt.content[0];
13053        let media = &media_single.content.as_ref().unwrap()[0];
13054        assert_eq!(media.node_type, "media");
13055        let marks = media.marks.as_ref().expect("media should have marks");
13056        assert_eq!(marks.len(), 1);
13057        assert_eq!(marks[0].mark_type, "border");
13058        let attrs = marks[0].attrs.as_ref().unwrap();
13059        assert_eq!(attrs["color"], "#091e4224");
13060        assert_eq!(attrs["size"], 2);
13061    }
13062
13063    #[test]
13064    fn media_external_border_mark_roundtrip() {
13065        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}}]}]}]}"##;
13066        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13067        let md = adf_to_markdown(&doc).unwrap();
13068        assert!(
13069            md.contains("border-color=#ff0000"),
13070            "external media should have border-color in md: {md}"
13071        );
13072        assert!(
13073            md.contains("border-size=3"),
13074            "external media should have border-size in md: {md}"
13075        );
13076        let rt = markdown_to_adf(&md).unwrap();
13077        let media = &rt.content[0].content.as_ref().unwrap()[0];
13078        let marks = media.marks.as_ref().expect("media should have marks");
13079        assert_eq!(marks[0].mark_type, "border");
13080        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#ff0000");
13081        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 3);
13082    }
13083
13084    #[test]
13085    fn media_file_no_border_mark_unchanged() {
13086        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}}]}]}"#;
13087        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13088        let md = adf_to_markdown(&doc).unwrap();
13089        assert!(
13090            !md.contains("border-color"),
13091            "no border attrs expected: {md}"
13092        );
13093        let rt = markdown_to_adf(&md).unwrap();
13094        let media = &rt.content[0].content.as_ref().unwrap()[0];
13095        assert!(media.marks.is_none(), "no marks expected on plain media");
13096    }
13097
13098    #[test]
13099    fn media_border_size_only_defaults_color() {
13100        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}}]}]}]}"#;
13101        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13102        let md = adf_to_markdown(&doc).unwrap();
13103        assert!(md.contains("border-size=4"), "md: {md}");
13104        let rt = markdown_to_adf(&md).unwrap();
13105        let media = &rt.content[0].content.as_ref().unwrap()[0];
13106        let marks = media.marks.as_ref().expect("should have border mark");
13107        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#000000");
13108        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 4);
13109    }
13110
13111    #[test]
13112    fn media_border_color_only_defaults_size() {
13113        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"}}]}]}]}"##;
13114        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13115        let md = adf_to_markdown(&doc).unwrap();
13116        assert!(md.contains("border-color=#00ff00"), "md: {md}");
13117        let rt = markdown_to_adf(&md).unwrap();
13118        let media = &rt.content[0].content.as_ref().unwrap()[0];
13119        let marks = media.marks.as_ref().expect("should have border mark");
13120        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#00ff00");
13121        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 1);
13122    }
13123
13124    #[test]
13125    fn media_border_with_other_attrs_roundtrip() {
13126        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}}]}]}]}"##;
13127        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13128        let md = adf_to_markdown(&doc).unwrap();
13129        assert!(md.contains("layout=wide"), "md: {md}");
13130        assert!(md.contains("mediaWidth=600"), "md: {md}");
13131        assert!(md.contains("border-color=#091e4224"), "md: {md}");
13132        assert!(md.contains("border-size=2"), "md: {md}");
13133        let rt = markdown_to_adf(&md).unwrap();
13134        let ms = &rt.content[0];
13135        assert_eq!(ms.attrs.as_ref().unwrap()["layout"], "wide");
13136        let media = &ms.content.as_ref().unwrap()[0];
13137        let marks = media.marks.as_ref().expect("should have marks");
13138        assert_eq!(marks[0].attrs.as_ref().unwrap()["color"], "#091e4224");
13139        assert_eq!(marks[0].attrs.as_ref().unwrap()["size"], 2);
13140    }
13141
13142    #[test]
13143    fn table_row_localid_roundtrip() {
13144        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"}]}]}]}]}]}"#;
13145        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13146        let md = adf_to_markdown(&doc).unwrap();
13147        assert!(
13148            md.contains("localId=tr-001"),
13149            "tableRow should have localId in md: {md}"
13150        );
13151        let rt = markdown_to_adf(&md).unwrap();
13152        let row = &rt.content[0].content.as_ref().unwrap()[0];
13153        assert_eq!(
13154            row.attrs.as_ref().unwrap()["localId"],
13155            "tr-001",
13156            "tableRow localId should round-trip"
13157        );
13158    }
13159
13160    #[test]
13161    fn list_item_localid_roundtrip() {
13162        // listItem localId is emitted as trailing inline attrs and parsed back
13163        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"}]}]}]}]}"#;
13164        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13165        let md = adf_to_markdown(&doc).unwrap();
13166        assert!(
13167            md.contains("localId=li-001"),
13168            "listItem should have localId in md: {md}"
13169        );
13170        // Verify localId is on the listItem, NOT promoted to bulletList
13171        let rt = markdown_to_adf(&md).unwrap();
13172        let list = &rt.content[0];
13173        assert!(
13174            list.attrs.is_none() || list.attrs.as_ref().unwrap().get("localId").is_none(),
13175            "bulletList should NOT have localId: {:?}",
13176            list.attrs
13177        );
13178        let item = &list.content.as_ref().unwrap()[0];
13179        assert_eq!(
13180            item.attrs.as_ref().unwrap()["localId"],
13181            "li-001",
13182            "listItem should have localId=li-001"
13183        );
13184    }
13185
13186    #[test]
13187    fn list_item_localid_not_promoted_to_parent() {
13188        // Verify localId stays on listItem and doesn't leak to parent list
13189        let md = "- item {localId=li-002}\n";
13190        let doc = markdown_to_adf(md).unwrap();
13191        let list = &doc.content[0];
13192        assert!(
13193            list.attrs.is_none(),
13194            "bulletList should have no attrs: {:?}",
13195            list.attrs
13196        );
13197        let item = &list.content.as_ref().unwrap()[0];
13198        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "li-002");
13199    }
13200
13201    #[test]
13202    fn ordered_list_item_localid_roundtrip() {
13203        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"}]}]}]}]}"#;
13204        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13205        let md = adf_to_markdown(&doc).unwrap();
13206        assert!(md.contains("localId=oli-001"), "md: {md}");
13207        let rt = markdown_to_adf(&md).unwrap();
13208        let item = &rt.content[0].content.as_ref().unwrap()[0];
13209        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13210    }
13211
13212    #[test]
13213    fn task_item_localid_roundtrip() {
13214        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"taskList","attrs":{"localId":"tl-001"},"content":[{"type":"taskItem","attrs":{"localId":"ti-001","state":"TODO"},"content":[{"type":"paragraph","content":[{"type":"text","text":"task"}]}]}]}]}"#;
13215        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13216        let md = adf_to_markdown(&doc).unwrap();
13217        assert!(md.contains("localId=ti-001"), "md: {md}");
13218        let rt = markdown_to_adf(&md).unwrap();
13219        let item = &rt.content[0].content.as_ref().unwrap()[0];
13220        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "ti-001");
13221    }
13222
13223    /// Issue #447: taskList with empty-string localId and taskItems with
13224    /// short numeric localIds must survive a full round-trip.
13225    #[test]
13226    fn task_list_short_localid_roundtrip() {
13227        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"}]}]}]}"#;
13228        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13229        let md = adf_to_markdown(&doc).unwrap();
13230        // Both taskItem localIds should appear in the markdown
13231        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13232        assert!(md.contains("localId=99"), "localId=99 missing: {md}");
13233        // Empty-string localId should NOT appear as {localId=}
13234        assert!(
13235            !md.contains("localId=}"),
13236            "empty localId should not be emitted: {md}"
13237        );
13238        let rt = markdown_to_adf(&md).unwrap();
13239        let task_list = &rt.content[0];
13240        assert_eq!(task_list.node_type, "taskList");
13241        // No spurious extra nodes from {localId=}
13242        assert_eq!(rt.content.len(), 1, "should be exactly one top-level node");
13243        let items = task_list.content.as_ref().unwrap();
13244        assert_eq!(items.len(), 2);
13245        // First taskItem: localId=42, state=TODO, no content
13246        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13247        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "TODO");
13248        assert!(
13249            items[0].content.is_none(),
13250            "empty taskItem should have no content: {:?}",
13251            items[0].content
13252        );
13253        // Second taskItem: localId=99, state=DONE, content with text
13254        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "99");
13255        assert_eq!(items[1].attrs.as_ref().unwrap()["state"], "DONE");
13256        let content = items[1].content.as_ref().unwrap();
13257        assert_eq!(content.len(), 1);
13258        assert_eq!(content[0].text.as_deref(), Some("done task"));
13259    }
13260
13261    /// Issue #507: numeric localId on taskItem with hardBreak must survive
13262    /// round-trip — the {localId=…} suffix lands on the continuation line
13263    /// and must still be extracted by the parser.
13264    #[test]
13265    fn task_item_numeric_localid_with_hardbreak_roundtrip() {
13266        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!!)"}]}]}]}]}"#;
13267        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13268        let md = adf_to_markdown(&doc).unwrap();
13269        // localId must appear in the markdown output
13270        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13271        // Round-trip back to ADF
13272        let rt = markdown_to_adf(&md).unwrap();
13273        assert_eq!(rt.content.len(), 1, "exactly one top-level node");
13274        let task_list = &rt.content[0];
13275        assert_eq!(task_list.node_type, "taskList");
13276        let items = task_list.content.as_ref().unwrap();
13277        assert_eq!(items.len(), 1);
13278        // localId preserved
13279        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13280        assert_eq!(items[0].attrs.as_ref().unwrap()["state"], "DONE");
13281        // Content structure preserved: paragraph with link + hardBreak + text
13282        let para = &items[0].content.as_ref().unwrap()[0];
13283        assert_eq!(para.node_type, "paragraph");
13284        let inlines = para.content.as_ref().unwrap();
13285        assert_eq!(inlines[0].node_type, "text");
13286        assert_eq!(
13287            inlines[0].text.as_deref(),
13288            Some("Engineering Onboarding Link")
13289        );
13290        assert_eq!(inlines[1].node_type, "hardBreak");
13291        assert_eq!(inlines[2].node_type, "text");
13292        assert_eq!(
13293            inlines[2].text.as_deref(),
13294            Some("(This has links to all the various useful tools!!)")
13295        );
13296        // The {localId=…} must not appear as literal text in the ADF output
13297        let rt_json = serde_json::to_string(&rt).unwrap();
13298        assert!(
13299            !rt_json.contains("{localId="),
13300            "localId attr syntax should not leak into ADF text: {rt_json}"
13301        );
13302    }
13303
13304    /// Issue #507: multiple taskItems with hardBreaks and numeric localIds.
13305    #[test]
13306    fn task_item_multiple_hardbreak_localids_roundtrip() {
13307        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"}]}]}]}]}"#;
13308        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13309        let md = adf_to_markdown(&doc).unwrap();
13310        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13311        assert!(md.contains("localId=67"), "localId=67 missing: {md}");
13312        let rt = markdown_to_adf(&md).unwrap();
13313        let items = rt.content[0].content.as_ref().unwrap();
13314        assert_eq!(items.len(), 2);
13315        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13316        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "67");
13317        // Verify hardBreak content structure for both items
13318        for item in items {
13319            let para = &item.content.as_ref().unwrap()[0];
13320            assert_eq!(para.node_type, "paragraph");
13321            let inlines = para.content.as_ref().unwrap();
13322            assert_eq!(inlines[1].node_type, "hardBreak");
13323        }
13324    }
13325
13326    /// Issue #521: sibling taskItems with numeric localIds and hardBreak —
13327    /// unwrapped inline content.  The hardBreak continuation line must be
13328    /// indented so it stays within the list item, and both localIds must
13329    /// survive the round-trip.
13330    #[test]
13331    fn task_item_sibling_localid_hardbreak_unwrapped_roundtrip() {
13332        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"}]}]}]}"#;
13333        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13334        let md = adf_to_markdown(&doc).unwrap();
13335        // Continuation line must be indented
13336        assert!(
13337            md.contains("  (parenthetical"),
13338            "continuation line should be 2-space indented: {md}"
13339        );
13340        assert!(md.contains("localId=42"), "localId=42 missing: {md}");
13341        assert!(md.contains("localId=69"), "localId=69 missing: {md}");
13342        let rt = markdown_to_adf(&md).unwrap();
13343        // Must remain a single taskList with 2 items
13344        assert_eq!(
13345            rt.content.len(),
13346            1,
13347            "should be one taskList: {:#?}",
13348            rt.content
13349        );
13350        assert_eq!(rt.content[0].node_type, "taskList");
13351        let items = rt.content[0].content.as_ref().unwrap();
13352        assert_eq!(items.len(), 2, "should have 2 taskItems");
13353        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13354        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13355        // Verify first item has hardBreak
13356        let first_content = items[0].content.as_ref().unwrap();
13357        assert!(
13358            first_content.iter().any(|n| n.node_type == "hardBreak"),
13359            "first item should contain hardBreak"
13360        );
13361        // Verify second item content
13362        let second_content = items[1].content.as_ref().unwrap();
13363        assert_eq!(second_content[0].node_type, "text");
13364        assert_eq!(
13365            second_content[0].text.as_deref().unwrap(),
13366            "second task item"
13367        );
13368    }
13369
13370    /// Issue #521: sibling taskItems with paragraph-wrapped content and
13371    /// hardBreak — localIds must not be swapped or lost.
13372    #[test]
13373    fn task_item_sibling_localid_hardbreak_paragraph_roundtrip() {
13374        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"}]}]}]}]}"#;
13375        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13376        let md = adf_to_markdown(&doc).unwrap();
13377        let rt = markdown_to_adf(&md).unwrap();
13378        assert_eq!(
13379            rt.content.len(),
13380            1,
13381            "should be one taskList: {:#?}",
13382            rt.content
13383        );
13384        let items = rt.content[0].content.as_ref().unwrap();
13385        assert_eq!(items.len(), 2);
13386        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "42");
13387        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "69");
13388    }
13389
13390    /// Issue #521: three sibling taskItems — the middle one has a hardBreak.
13391    /// Ensures localIds don't leak between adjacent items.
13392    #[test]
13393    fn task_item_three_siblings_middle_hardbreak_roundtrip() {
13394        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"}]}]}]}"#;
13395        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13396        let md = adf_to_markdown(&doc).unwrap();
13397        let rt = markdown_to_adf(&md).unwrap();
13398        assert_eq!(rt.content.len(), 1);
13399        let items = rt.content[0].content.as_ref().unwrap();
13400        assert_eq!(items.len(), 3);
13401        assert_eq!(items[0].attrs.as_ref().unwrap()["localId"], "10");
13402        assert_eq!(items[1].attrs.as_ref().unwrap()["localId"], "20");
13403        assert_eq!(items[2].attrs.as_ref().unwrap()["localId"], "30");
13404        // Middle item should have hardBreak
13405        let mid_content = items[1].content.as_ref().unwrap();
13406        assert!(mid_content.iter().any(|n| n.node_type == "hardBreak"));
13407    }
13408
13409    /// Issue #447: regression — taskList with empty localId must not inject
13410    /// a spurious paragraph.
13411    #[test]
13412    fn task_list_empty_localid_no_spurious_paragraph() {
13413        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"}]}]}]}"#;
13414        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13415        let md = adf_to_markdown(&doc).unwrap();
13416        assert!(
13417            !md.contains("{localId=}"),
13418            "empty localId should not be emitted: {md}"
13419        );
13420        let rt = markdown_to_adf(&md).unwrap();
13421        assert_eq!(
13422            rt.content.len(),
13423            1,
13424            "no spurious paragraph: {:#?}",
13425            rt.content
13426        );
13427        assert_eq!(rt.content[0].node_type, "taskList");
13428    }
13429
13430    /// Issue #447: taskList localId should be stripped when strip_local_ids is set.
13431    #[test]
13432    fn task_list_localid_stripped() {
13433        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"}]}]}]}]}"#;
13434        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13435        let opts = RenderOptions {
13436            strip_local_ids: true,
13437        };
13438        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13439        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13440    }
13441
13442    /// Issue #447: taskItem with no content still emits localId.
13443    #[test]
13444    fn task_item_no_content_emits_localid() {
13445        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"}}]}]}"#;
13446        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13447        let md = adf_to_markdown(&doc).unwrap();
13448        assert!(
13449            md.contains("localId=abc"),
13450            "localId should be emitted even without content: {md}"
13451        );
13452        let rt = markdown_to_adf(&md).unwrap();
13453        let item = &rt.content[0].content.as_ref().unwrap()[0];
13454        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "abc");
13455        assert!(item.content.is_none(), "should have no content");
13456    }
13457
13458    /// Issue #447: taskList localId roundtrips through block attrs.
13459    #[test]
13460    fn task_list_localid_roundtrip() {
13461        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"}]}]}]}]}"#;
13462        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13463        let md = adf_to_markdown(&doc).unwrap();
13464        assert!(
13465            md.contains("localId=tl-xyz"),
13466            "taskList localId missing: {md}"
13467        );
13468        let rt = markdown_to_adf(&md).unwrap();
13469        assert_eq!(
13470            rt.content[0].attrs.as_ref().unwrap()["localId"],
13471            "tl-xyz",
13472            "taskList localId should survive round-trip"
13473        );
13474    }
13475
13476    /// Issue #478: taskItem with paragraph wrapper (no localId) preserves wrapper on round-trip.
13477    #[test]
13478    fn task_item_paragraph_wrapper_roundtrip_no_localid() {
13479        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"}]}]}]}]}"#;
13480        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13481        let md = adf_to_markdown(&doc).unwrap();
13482        assert!(
13483            md.contains("paraLocalId=_"),
13484            "should emit paraLocalId=_ sentinel: {md}"
13485        );
13486        let rt = markdown_to_adf(&md).unwrap();
13487        let item = &rt.content[0].content.as_ref().unwrap()[0];
13488        let content = item.content.as_ref().unwrap();
13489        assert_eq!(content.len(), 1, "should have one child: {content:#?}");
13490        assert_eq!(
13491            content[0].node_type, "paragraph",
13492            "child should be a paragraph: {content:#?}"
13493        );
13494        let para_content = content[0].content.as_ref().unwrap();
13495        assert_eq!(
13496            para_content[0].text.as_deref(),
13497            Some("A task with paragraph wrapper")
13498        );
13499        // Paragraph should have no attrs (localId was absent in the original)
13500        assert!(
13501            content[0].attrs.is_none(),
13502            "paragraph should have no attrs: {:?}",
13503            content[0].attrs
13504        );
13505    }
13506
13507    /// Issue #478: taskItem with paragraph wrapper AND paraLocalId preserves both.
13508    #[test]
13509    fn task_item_paragraph_wrapper_roundtrip_with_localid() {
13510        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"}]}]}]}]}"#;
13511        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13512        let md = adf_to_markdown(&doc).unwrap();
13513        assert!(
13514            md.contains("paraLocalId=p-001"),
13515            "should emit paraLocalId=p-001: {md}"
13516        );
13517        let rt = markdown_to_adf(&md).unwrap();
13518        let item = &rt.content[0].content.as_ref().unwrap()[0];
13519        let content = item.content.as_ref().unwrap();
13520        assert_eq!(content[0].node_type, "paragraph");
13521        assert_eq!(
13522            content[0].attrs.as_ref().unwrap()["localId"],
13523            "p-001",
13524            "paragraph localId should be preserved"
13525        );
13526    }
13527
13528    /// Issue #478: taskItem WITHOUT paragraph wrapper (unwrapped inline) still round-trips correctly.
13529    #[test]
13530    fn task_item_unwrapped_inline_no_paragraph_on_roundtrip() {
13531        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"}]}]}]}"#;
13532        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13533        let md = adf_to_markdown(&doc).unwrap();
13534        assert!(
13535            !md.contains("paraLocalId"),
13536            "should NOT emit paraLocalId for unwrapped inline: {md}"
13537        );
13538        let rt = markdown_to_adf(&md).unwrap();
13539        let item = &rt.content[0].content.as_ref().unwrap()[0];
13540        let content = item.content.as_ref().unwrap();
13541        assert_eq!(
13542            content[0].node_type, "text",
13543            "should remain unwrapped: {content:#?}"
13544        );
13545    }
13546
13547    /// Issue #478: DONE taskItem with paragraph wrapper round-trips.
13548    #[test]
13549    fn task_item_done_paragraph_wrapper_roundtrip() {
13550        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"}]}]}]}]}"#;
13551        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13552        let md = adf_to_markdown(&doc).unwrap();
13553        assert!(md.contains("- [x]"), "should render as done: {md}");
13554        let rt = markdown_to_adf(&md).unwrap();
13555        let item = &rt.content[0].content.as_ref().unwrap()[0];
13556        assert_eq!(item.attrs.as_ref().unwrap()["state"], "DONE");
13557        let content = item.content.as_ref().unwrap();
13558        assert_eq!(content[0].node_type, "paragraph");
13559    }
13560
13561    /// Issue #478: mixed taskItems — some with paragraph wrapper, some without.
13562    #[test]
13563    fn task_item_mixed_paragraph_and_unwrapped_roundtrip() {
13564        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"}]}]}]}"#;
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 items = rt.content[0].content.as_ref().unwrap();
13569        assert_eq!(items.len(), 2);
13570        // First item: paragraph wrapper preserved
13571        let c1 = items[0].content.as_ref().unwrap();
13572        assert_eq!(
13573            c1[0].node_type, "paragraph",
13574            "first item should have paragraph wrapper"
13575        );
13576        // Second item: no paragraph wrapper
13577        let c2 = items[1].content.as_ref().unwrap();
13578        assert_eq!(
13579            c2[0].node_type, "text",
13580            "second item should remain unwrapped"
13581        );
13582    }
13583
13584    /// Issue #478: taskItem with paragraph wrapper containing marks round-trips.
13585    #[test]
13586    fn task_item_paragraph_wrapper_with_marks_roundtrip() {
13587        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"}]}]}]}]}]}"#;
13588        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13589        let md = adf_to_markdown(&doc).unwrap();
13590        let rt = markdown_to_adf(&md).unwrap();
13591        let item = &rt.content[0].content.as_ref().unwrap()[0];
13592        let content = item.content.as_ref().unwrap();
13593        assert_eq!(content[0].node_type, "paragraph");
13594        let para_children = content[0].content.as_ref().unwrap();
13595        assert!(
13596            para_children.len() >= 2,
13597            "paragraph should contain multiple inline nodes"
13598        );
13599    }
13600
13601    /// Issue #478: strip_local_ids suppresses the paraLocalId=_ sentinel too.
13602    #[test]
13603    fn task_item_paragraph_wrapper_stripped_with_option() {
13604        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"}]}]}]}]}"#;
13605        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13606        let opts = RenderOptions {
13607            strip_local_ids: true,
13608        };
13609        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13610        assert!(
13611            !md.contains("paraLocalId"),
13612            "paraLocalId should be stripped: {md}"
13613        );
13614        assert!(
13615            !md.contains("localId"),
13616            "all localIds should be stripped: {md}"
13617        );
13618    }
13619
13620    #[test]
13621    fn trailing_space_preserved_with_hex_localid() {
13622        // Issue #449: trailing whitespace stripped from text node
13623        // when listItem has a hex-format localId (no hyphens)
13624        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 "}]}]}]}]}"#;
13625        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13626        let md = adf_to_markdown(&doc).unwrap();
13627        let rt = markdown_to_adf(&md).unwrap();
13628        let item = &rt.content[0].content.as_ref().unwrap()[0];
13629        assert_eq!(
13630            item.attrs.as_ref().unwrap()["localId"],
13631            "aabb112233cc",
13632            "localId should round-trip"
13633        );
13634        let para = &item.content.as_ref().unwrap()[0];
13635        let inlines = para.content.as_ref().unwrap();
13636        let last = inlines.last().unwrap();
13637        assert!(
13638            last.text.as_deref().unwrap_or("").ends_with(' '),
13639            "trailing space should be preserved, got nodes: {:?}",
13640            inlines
13641                .iter()
13642                .map(|n| (&n.node_type, &n.text))
13643                .collect::<Vec<_>>()
13644        );
13645    }
13646
13647    #[test]
13648    fn extract_trailing_local_id_preserves_trailing_space() {
13649        // Issue #449: only strip the single separator space before {localId=...}
13650        let (before, lid, _) = extract_trailing_local_id("trailing space  {localId=aabb112233cc}");
13651        assert_eq!(before, "trailing space ");
13652        assert_eq!(lid.as_deref(), Some("aabb112233cc"));
13653    }
13654
13655    #[test]
13656    fn extract_trailing_local_id_no_trailing_space() {
13657        let (before, lid, _) = extract_trailing_local_id("text {localId=abc123}");
13658        assert_eq!(before, "text");
13659        assert_eq!(lid.as_deref(), Some("abc123"));
13660    }
13661
13662    #[test]
13663    fn extract_trailing_local_id_no_attrs() {
13664        let (before, lid, pid) = extract_trailing_local_id("plain text");
13665        assert_eq!(before, "plain text");
13666        assert!(lid.is_none());
13667        assert!(pid.is_none());
13668    }
13669
13670    #[test]
13671    fn list_item_localid_stripped() {
13672        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"}]}]}]}]}"#;
13673        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13674        let opts = RenderOptions {
13675            strip_local_ids: true,
13676        };
13677        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13678        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13679    }
13680
13681    #[test]
13682    fn paragraph_localid_in_list_item_roundtrip() {
13683        // Issue #417: paragraph.attrs.localId dropped in listItem context
13684        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"}]}]}]}]}"#;
13685        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13686        let md = adf_to_markdown(&doc).unwrap();
13687        assert!(
13688            md.contains("paraLocalId=para-001"),
13689            "paragraph localId should be in md: {md}"
13690        );
13691        let rt = markdown_to_adf(&md).unwrap();
13692        let item = &rt.content[0].content.as_ref().unwrap()[0];
13693        assert_eq!(
13694            item.attrs.as_ref().unwrap()["localId"],
13695            "item-001",
13696            "listItem localId should survive"
13697        );
13698        let para = &item.content.as_ref().unwrap()[0];
13699        assert_eq!(
13700            para.attrs.as_ref().unwrap()["localId"],
13701            "para-001",
13702            "paragraph localId should survive round-trip"
13703        );
13704    }
13705
13706    #[test]
13707    fn paragraph_localid_in_ordered_list_item_roundtrip() {
13708        // Issue #417: paragraph localId in ordered list
13709        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"}]}]}]}]}"#;
13710        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13711        let md = adf_to_markdown(&doc).unwrap();
13712        assert!(md.contains("paraLocalId=op-001"), "md: {md}");
13713        let rt = markdown_to_adf(&md).unwrap();
13714        let item = &rt.content[0].content.as_ref().unwrap()[0];
13715        assert_eq!(item.attrs.as_ref().unwrap()["localId"], "oli-001");
13716        let para = &item.content.as_ref().unwrap()[0];
13717        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "op-001");
13718    }
13719
13720    #[test]
13721    fn paragraph_localid_only_in_list_item() {
13722        // paragraph has localId but listItem does not
13723        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"}]}]}]}]}"#;
13724        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13725        let md = adf_to_markdown(&doc).unwrap();
13726        assert!(
13727            md.contains("paraLocalId=para-only"),
13728            "paragraph localId should be emitted: {md}"
13729        );
13730        let rt = markdown_to_adf(&md).unwrap();
13731        let item = &rt.content[0].content.as_ref().unwrap()[0];
13732        assert!(item.attrs.is_none(), "listItem should have no attrs");
13733        let para = &item.content.as_ref().unwrap()[0];
13734        assert_eq!(para.attrs.as_ref().unwrap()["localId"], "para-only");
13735    }
13736
13737    #[test]
13738    fn paragraph_localid_in_table_header_roundtrip() {
13739        // Issue #417: paragraph.attrs.localId dropped in tableHeader context
13740        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"}]}]}]}]}]}"#;
13741        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13742        let md = adf_to_markdown(&doc).unwrap();
13743        // Should use directive form (not pipe table) to preserve paragraph localId
13744        assert!(
13745            md.contains("localId=aaaa-aaaa"),
13746            "paragraph localId should be in md: {md}"
13747        );
13748        let rt = markdown_to_adf(&md).unwrap();
13749        let cell = &rt.content[0].content.as_ref().unwrap()[0]
13750            .content
13751            .as_ref()
13752            .unwrap()[0];
13753        let para = &cell.content.as_ref().unwrap()[0];
13754        assert_eq!(
13755            para.attrs.as_ref().unwrap()["localId"],
13756            "aaaa-aaaa",
13757            "paragraph localId should survive round-trip in tableHeader"
13758        );
13759    }
13760
13761    #[test]
13762    fn paragraph_localid_in_table_cell_roundtrip() {
13763        // Issue #417: paragraph localId in tableCell forces directive table
13764        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"}]}]}]}]}]}"#;
13765        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13766        let md = adf_to_markdown(&doc).unwrap();
13767        assert!(
13768            md.contains("localId=cell-para"),
13769            "paragraph localId should be in md: {md}"
13770        );
13771        let rt = markdown_to_adf(&md).unwrap();
13772        // Data row -> cell -> paragraph
13773        let cell = &rt.content[0].content.as_ref().unwrap()[1]
13774            .content
13775            .as_ref()
13776            .unwrap()[0];
13777        let para = &cell.content.as_ref().unwrap()[0];
13778        assert_eq!(
13779            para.attrs.as_ref().unwrap()["localId"],
13780            "cell-para",
13781            "paragraph localId should survive round-trip in tableCell"
13782        );
13783    }
13784
13785    #[test]
13786    fn nbsp_paragraph_with_localid_roundtrip() {
13787        // Issue #417: nbsp paragraph localId emitted as text instead of attrs
13788        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"nbsp-para"},"content":[{"type":"text","text":"\u00a0"}]}]}"#;
13789        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13790        let md = adf_to_markdown(&doc).unwrap();
13791        assert!(
13792            md.contains("::paragraph["),
13793            "nbsp should use directive form: {md}"
13794        );
13795        assert!(
13796            md.contains("localId=nbsp-para"),
13797            "localId should be in directive: {md}"
13798        );
13799        let rt = markdown_to_adf(&md).unwrap();
13800        let para = &rt.content[0];
13801        assert_eq!(
13802            para.attrs.as_ref().unwrap()["localId"],
13803            "nbsp-para",
13804            "localId should survive round-trip"
13805        );
13806        let text = para.content.as_ref().unwrap()[0].text.as_ref().unwrap();
13807        assert_eq!(text, "\u{00a0}", "nbsp should survive");
13808    }
13809
13810    #[test]
13811    fn empty_paragraph_with_localid_roundtrip() {
13812        // Empty paragraph directive with localId
13813        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","attrs":{"localId":"empty-para"}}]}"#;
13814        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13815        let md = adf_to_markdown(&doc).unwrap();
13816        assert!(
13817            md.contains("::paragraph{localId=empty-para}"),
13818            "empty paragraph should include localId in directive: {md}"
13819        );
13820        let rt = markdown_to_adf(&md).unwrap();
13821        assert_eq!(
13822            rt.content[0].attrs.as_ref().unwrap()["localId"],
13823            "empty-para"
13824        );
13825    }
13826
13827    #[test]
13828    fn paragraph_localid_stripped_from_list_item() {
13829        // strip_local_ids should also strip paraLocalId
13830        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"}]}]}]}]}"#;
13831        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13832        let opts = RenderOptions {
13833            strip_local_ids: true,
13834        };
13835        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
13836        assert!(!md.contains("localId"), "localId should be stripped: {md}");
13837        assert!(
13838            !md.contains("paraLocalId"),
13839            "paraLocalId should be stripped: {md}"
13840        );
13841    }
13842
13843    #[test]
13844    fn date_directive() {
13845        let doc = markdown_to_adf("Due by :date[2026-04-15].").unwrap();
13846        let content = doc.content[0].content.as_ref().unwrap();
13847        assert_eq!(content[1].node_type, "date");
13848        // ISO date is converted to epoch milliseconds
13849        assert_eq!(
13850            content[1].attrs.as_ref().unwrap()["timestamp"],
13851            "1776211200000"
13852        );
13853    }
13854
13855    #[test]
13856    fn adf_date_to_markdown() {
13857        // ADF dates use epoch ms; renderer converts back to ISO with timestamp attr
13858        let doc = AdfDocument {
13859            version: 1,
13860            doc_type: "doc".to_string(),
13861            content: vec![AdfNode::paragraph(vec![AdfNode::date("1776211200000")])],
13862        };
13863        let md = adf_to_markdown(&doc).unwrap();
13864        assert!(md.contains(":date[2026-04-15]{timestamp=1776211200000}"));
13865    }
13866
13867    #[test]
13868    fn adf_date_iso_passthrough() {
13869        // If ADF already has ISO date (legacy), pass through
13870        let doc = AdfDocument {
13871            version: 1,
13872            doc_type: "doc".to_string(),
13873            content: vec![AdfNode::paragraph(vec![AdfNode::date("2026-04-15")])],
13874        };
13875        let md = adf_to_markdown(&doc).unwrap();
13876        assert!(md.contains(":date[2026-04-15]{timestamp=2026-04-15}"));
13877    }
13878
13879    #[test]
13880    fn round_trip_date() {
13881        let md = "Due by :date[2026-04-15].\n";
13882        let doc = markdown_to_adf(md).unwrap();
13883        let result = adf_to_markdown(&doc).unwrap();
13884        assert!(result.contains(":date[2026-04-15]"));
13885    }
13886
13887    #[test]
13888    fn round_trip_date_non_midnight_timestamp() {
13889        // Issue #409: non-midnight timestamps must survive round-trip
13890        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000"}}]}]}"#;
13891        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13892        let md = adf_to_markdown(&doc).unwrap();
13893        // JFM should include the original timestamp
13894        assert!(
13895            md.contains("timestamp=1700000000000"),
13896            "JFM should preserve original timestamp: {md}"
13897        );
13898        // Round-trip back to ADF
13899        let doc2 = markdown_to_adf(&md).unwrap();
13900        let content = doc2.content[0].content.as_ref().unwrap();
13901        assert_eq!(
13902            content[0].attrs.as_ref().unwrap()["timestamp"],
13903            "1700000000000",
13904            "Round-trip must preserve original non-midnight timestamp"
13905        );
13906    }
13907
13908    #[test]
13909    fn date_epoch_ms_passthrough() {
13910        // If JFM date is already epoch ms, pass through
13911        let doc = markdown_to_adf("Due by :date[1776211200000].").unwrap();
13912        let content = doc.content[0].content.as_ref().unwrap();
13913        assert_eq!(
13914            content[1].attrs.as_ref().unwrap()["timestamp"],
13915            "1776211200000"
13916        );
13917    }
13918
13919    #[test]
13920    fn date_timestamp_attr_preferred_over_content() {
13921        // When timestamp attr is present, it takes priority over the display date
13922        let md = ":date[2023-11-14]{timestamp=1700000000000}\n";
13923        let doc = markdown_to_adf(md).unwrap();
13924        let content = doc.content[0].content.as_ref().unwrap();
13925        assert_eq!(
13926            content[0].attrs.as_ref().unwrap()["timestamp"],
13927            "1700000000000",
13928            "timestamp attr should be used directly"
13929        );
13930    }
13931
13932    #[test]
13933    fn date_without_timestamp_attr_backward_compat() {
13934        // Legacy JFM without timestamp attr still works via iso_date_to_epoch_ms
13935        let md = ":date[2026-04-15]\n";
13936        let doc = markdown_to_adf(md).unwrap();
13937        let content = doc.content[0].content.as_ref().unwrap();
13938        assert_eq!(
13939            content[0].attrs.as_ref().unwrap()["timestamp"],
13940            "1776211200000",
13941            "Should fall back to computing timestamp from date string"
13942        );
13943    }
13944
13945    #[test]
13946    fn date_with_local_id_and_timestamp() {
13947        // Both localId and timestamp should round-trip
13948        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"date","attrs":{"timestamp":"1700000000000","localId":"d-001"}}]}]}"#;
13949        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
13950        let md = adf_to_markdown(&doc).unwrap();
13951        assert!(
13952            md.contains("timestamp=1700000000000"),
13953            "Should contain timestamp: {md}"
13954        );
13955        assert!(md.contains("localId=d-001"), "Should contain localId: {md}");
13956        // Round-trip
13957        let doc2 = markdown_to_adf(&md).unwrap();
13958        let content = doc2.content[0].content.as_ref().unwrap();
13959        let attrs = content[0].attrs.as_ref().unwrap();
13960        assert_eq!(attrs["timestamp"], "1700000000000");
13961        assert_eq!(attrs["localId"], "d-001");
13962    }
13963
13964    #[test]
13965    fn mention_directive() {
13966        let doc = markdown_to_adf("Assigned to :mention[Alice]{id=abc123}.").unwrap();
13967        let content = doc.content[0].content.as_ref().unwrap();
13968        assert_eq!(content[1].node_type, "mention");
13969        assert_eq!(content[1].attrs.as_ref().unwrap()["id"], "abc123");
13970        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "Alice");
13971    }
13972
13973    #[test]
13974    fn adf_mention_to_markdown() {
13975        let doc = AdfDocument {
13976            version: 1,
13977            doc_type: "doc".to_string(),
13978            content: vec![AdfNode::paragraph(vec![AdfNode::mention(
13979                "abc123", "Alice",
13980            )])],
13981        };
13982        let md = adf_to_markdown(&doc).unwrap();
13983        assert!(md.contains(":mention[Alice]{id=abc123}"));
13984    }
13985
13986    #[test]
13987    fn round_trip_mention() {
13988        let md = "Assigned to :mention[Alice]{id=abc123}.\n";
13989        let doc = markdown_to_adf(md).unwrap();
13990        let result = adf_to_markdown(&doc).unwrap();
13991        assert!(result.contains(":mention[Alice]{id=abc123}"));
13992    }
13993
13994    #[test]
13995    fn mention_with_empty_access_level_round_trips() {
13996        // Issue #363: accessLevel="" produces accessLevel= which failed to parse
13997        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
13998          {"type":"mention","attrs":{"id":"61921b41c15977006af2b1d1","text":"@Javier Inchausti","accessLevel":""}}
13999        ]}]}"#;
14000        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14001
14002        let md = adf_to_markdown(&doc).unwrap();
14003        let round_tripped = markdown_to_adf(&md).unwrap();
14004        let mention = &round_tripped.content[0].content.as_ref().unwrap()[0];
14005        assert_eq!(
14006            mention.node_type, "mention",
14007            "mention with empty accessLevel was not parsed as mention, got: {}",
14008            mention.node_type
14009        );
14010    }
14011
14012    #[test]
14013    fn span_with_color() {
14014        let doc = markdown_to_adf("This is :span[red text]{color=#ff5630}.").unwrap();
14015        let content = doc.content[0].content.as_ref().unwrap();
14016        assert_eq!(content[1].node_type, "text");
14017        assert_eq!(content[1].text.as_deref(), Some("red text"));
14018        let marks = content[1].marks.as_ref().unwrap();
14019        assert_eq!(marks[0].mark_type, "textColor");
14020    }
14021
14022    #[test]
14023    fn adf_text_color_to_markdown() {
14024        let doc = AdfDocument {
14025            version: 1,
14026            doc_type: "doc".to_string(),
14027            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
14028                "red text",
14029                vec![AdfMark::text_color("#ff5630")],
14030            )])],
14031        };
14032        let md = adf_to_markdown(&doc).unwrap();
14033        assert!(md.contains(":span[red text]{color=#ff5630}"));
14034    }
14035
14036    #[test]
14037    fn round_trip_span_color() {
14038        let md = "This is :span[red text]{color=#ff5630}.\n";
14039        let doc = markdown_to_adf(md).unwrap();
14040        let result = adf_to_markdown(&doc).unwrap();
14041        assert!(result.contains(":span[red text]{color=#ff5630}"));
14042    }
14043
14044    #[test]
14045    fn text_color_and_link_marks_both_preserved() {
14046        // Issue #405: text with both textColor and link marks loses link on round-trip
14047        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14048          {"type":"text","text":"red link","marks":[
14049            {"type":"link","attrs":{"href":"https://example.com"}},
14050            {"type":"textColor","attrs":{"color":"#ff0000"}}
14051          ]}
14052        ]}]}"##;
14053        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14054        let md = adf_to_markdown(&doc).unwrap();
14055        assert!(
14056            md.contains(":span[red link]{color=#ff0000}"),
14057            "JFM should contain span with color, got: {md}"
14058        );
14059        assert!(
14060            md.contains("](https://example.com)"),
14061            "JFM should contain link href, got: {md}"
14062        );
14063        // Full round-trip: both marks survive
14064        let rt = markdown_to_adf(&md).unwrap();
14065        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14066        let marks = text_node.marks.as_ref().expect("should have marks");
14067        assert!(
14068            marks.iter().any(|m| m.mark_type == "textColor"),
14069            "should have textColor mark, got: {:?}",
14070            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14071        );
14072        assert!(
14073            marks.iter().any(|m| m.mark_type == "link"),
14074            "should have link mark, got: {:?}",
14075            marks.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
14076        );
14077        // Verify attribute values survive
14078        let link_mark = marks.iter().find(|m| m.mark_type == "link").unwrap();
14079        assert_eq!(
14080            link_mark.attrs.as_ref().unwrap()["href"],
14081            "https://example.com"
14082        );
14083        let color_mark = marks.iter().find(|m| m.mark_type == "textColor").unwrap();
14084        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#ff0000");
14085    }
14086
14087    #[test]
14088    fn bg_color_and_link_marks_both_preserved() {
14089        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14090          {"type":"text","text":"highlighted link","marks":[
14091            {"type":"link","attrs":{"href":"https://example.com"}},
14092            {"type":"backgroundColor","attrs":{"color":"#ffff00"}}
14093          ]}
14094        ]}]}"##;
14095        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14096        let md = adf_to_markdown(&doc).unwrap();
14097        assert!(md.contains("bg=#ffff00"), "should have bg color: {md}");
14098        assert!(
14099            md.contains("](https://example.com)"),
14100            "should have link: {md}"
14101        );
14102        let rt = markdown_to_adf(&md).unwrap();
14103        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14104        let marks = text_node.marks.as_ref().expect("should have marks");
14105        assert!(marks.iter().any(|m| m.mark_type == "backgroundColor"));
14106        assert!(marks.iter().any(|m| m.mark_type == "link"));
14107    }
14108
14109    #[test]
14110    fn text_color_link_and_strong_rendering() {
14111        // Verify textColor + link + strong renders all three formatting elements
14112        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14113          {"type":"text","text":"bold red link","marks":[
14114            {"type":"strong"},
14115            {"type":"link","attrs":{"href":"https://example.com"}},
14116            {"type":"textColor","attrs":{"color":"#ff0000"}}
14117          ]}
14118        ]}]}"##;
14119        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14120        let md = adf_to_markdown(&doc).unwrap();
14121        assert!(
14122            md.starts_with("**") && md.trim().ends_with("**"),
14123            "should have bold wrapping: {md}"
14124        );
14125        assert!(md.contains("color=#ff0000"), "should have color: {md}");
14126        assert!(
14127            md.contains("](https://example.com)"),
14128            "should have link: {md}"
14129        );
14130    }
14131
14132    #[test]
14133    fn subsup_and_link_marks_both_preserved() {
14134        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14135          {"type":"text","text":"note","marks":[
14136            {"type":"link","attrs":{"href":"https://example.com"}},
14137            {"type":"subsup","attrs":{"type":"sup"}}
14138          ]}
14139        ]}]}"#;
14140        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14141        let md = adf_to_markdown(&doc).unwrap();
14142        assert!(md.contains("sup"), "should have sup: {md}");
14143        assert!(
14144            md.contains("](https://example.com)"),
14145            "should have link: {md}"
14146        );
14147        let rt = markdown_to_adf(&md).unwrap();
14148        let text_node = &rt.content[0].content.as_ref().unwrap()[0];
14149        let marks = text_node.marks.as_ref().expect("should have marks");
14150        assert!(marks.iter().any(|m| m.mark_type == "subsup"));
14151        assert!(marks.iter().any(|m| m.mark_type == "link"));
14152    }
14153
14154    #[test]
14155    fn text_color_without_link_unchanged() {
14156        // Regression guard: textColor without link should still work
14157        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
14158          {"type":"text","text":"just red","marks":[
14159            {"type":"textColor","attrs":{"color":"#ff0000"}}
14160          ]}
14161        ]}]}"##;
14162        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14163        let md = adf_to_markdown(&doc).unwrap();
14164        assert!(md.contains(":span[just red]{color=#ff0000}"), "md: {md}");
14165        assert!(!md.contains("](http"), "should NOT have link syntax: {md}");
14166    }
14167
14168    #[test]
14169    fn inline_extension_directive() {
14170        let doc =
14171            markdown_to_adf("See :extension[fallback]{type=com.app key=widget} here.").unwrap();
14172        let content = doc.content[0].content.as_ref().unwrap();
14173        assert_eq!(content[1].node_type, "inlineExtension");
14174        assert_eq!(
14175            content[1].attrs.as_ref().unwrap()["extensionType"],
14176            "com.app"
14177        );
14178        assert_eq!(content[1].attrs.as_ref().unwrap()["extensionKey"], "widget");
14179    }
14180
14181    #[test]
14182    fn adf_inline_extension_to_markdown() {
14183        let doc = AdfDocument {
14184            version: 1,
14185            doc_type: "doc".to_string(),
14186            content: vec![AdfNode::paragraph(vec![AdfNode::inline_extension(
14187                "com.app",
14188                "widget",
14189                Some("fallback"),
14190            )])],
14191        };
14192        let md = adf_to_markdown(&doc).unwrap();
14193        assert!(md.contains(":extension[fallback]{type=com.app key=widget}"));
14194    }
14195
14196    // ── Helper function tests ──────────────────────────────────────────
14197
14198    #[test]
14199    fn parse_ordered_list_marker_valid() {
14200        let result = parse_ordered_list_marker("1. Hello");
14201        assert_eq!(result, Some((1, "Hello")));
14202    }
14203
14204    #[test]
14205    fn parse_ordered_list_marker_high_number() {
14206        let result = parse_ordered_list_marker("42. Item");
14207        assert_eq!(result, Some((42, "Item")));
14208    }
14209
14210    #[test]
14211    fn parse_ordered_list_marker_not_a_list() {
14212        assert!(parse_ordered_list_marker("not a list").is_none());
14213        assert!(parse_ordered_list_marker("1.no space").is_none());
14214    }
14215
14216    #[test]
14217    fn is_list_start_various() {
14218        assert!(is_list_start("- item"));
14219        assert!(is_list_start("* item"));
14220        assert!(is_list_start("+ item"));
14221        assert!(is_list_start("1. item"));
14222        assert!(!is_list_start("not a list"));
14223    }
14224
14225    #[test]
14226    fn is_horizontal_rule_various() {
14227        assert!(is_horizontal_rule("---"));
14228        assert!(is_horizontal_rule("***"));
14229        assert!(is_horizontal_rule("___"));
14230        assert!(is_horizontal_rule("------"));
14231        assert!(!is_horizontal_rule("--"));
14232        assert!(!is_horizontal_rule("abc"));
14233    }
14234
14235    #[test]
14236    fn is_table_separator_valid() {
14237        assert!(is_table_separator("| --- | --- |"));
14238        assert!(is_table_separator("|:---:|:---|"));
14239        assert!(!is_table_separator("no pipes here"));
14240    }
14241
14242    #[test]
14243    fn parse_table_row_cells() {
14244        let cells = parse_table_row("| A | B | C |");
14245        assert_eq!(cells, vec!["A", "B", "C"]);
14246    }
14247
14248    #[test]
14249    fn parse_table_row_escaped_pipe_in_cell() {
14250        // Issue #579: `\|` inside a cell is a literal pipe, not a column separator.
14251        let cells = parse_table_row(r"| a\|b | c |");
14252        assert_eq!(cells, vec!["a|b", "c"]);
14253    }
14254
14255    #[test]
14256    fn parse_table_row_escaped_pipe_in_code_span() {
14257        // Issue #579: `\|` inside an inline code span is unescaped at the row level.
14258        let cells = parse_table_row(r"| `parser.decode[T\|json]` | other |");
14259        assert_eq!(cells, vec!["`parser.decode[T|json]`", "other"]);
14260    }
14261
14262    #[test]
14263    fn parse_table_row_preserves_other_backslashes() {
14264        // Only `\|` is special at the row-splitting level; other backslashes pass through.
14265        let cells = parse_table_row(r"| a\\b | c\*d |");
14266        assert_eq!(cells, vec![r"a\\b", r"c\*d"]);
14267    }
14268
14269    #[test]
14270    fn parse_image_syntax_valid() {
14271        let result = parse_image_syntax("![alt](url)");
14272        assert_eq!(result, Some(("alt", "url")));
14273    }
14274
14275    #[test]
14276    fn parse_image_syntax_not_image() {
14277        assert!(parse_image_syntax("not an image").is_none());
14278    }
14279
14280    // ── find_closing_paren tests ────────────────────────────────────
14281
14282    #[test]
14283    fn find_closing_paren_simple() {
14284        assert_eq!(find_closing_paren("(hello)", 0), Some(6));
14285    }
14286
14287    #[test]
14288    fn find_closing_paren_nested() {
14289        assert_eq!(find_closing_paren("(a(b)c)", 0), Some(6));
14290    }
14291
14292    #[test]
14293    fn find_closing_paren_unmatched() {
14294        assert_eq!(find_closing_paren("(no close", 0), None);
14295    }
14296
14297    #[test]
14298    fn find_closing_paren_offset() {
14299        // Start scanning from the second '('
14300        assert_eq!(find_closing_paren("xx(inner)", 2), Some(8));
14301    }
14302
14303    // ── Parentheses-in-URL tests (issue #509) ──────────────────────
14304
14305    #[test]
14306    fn try_parse_link_url_with_parens() {
14307        let input = "[here](https://example.com/faq#access-(permissions)-rest)";
14308        let result = try_parse_link(input, 0);
14309        assert_eq!(
14310            result,
14311            Some((
14312                input.len(),
14313                "here",
14314                "https://example.com/faq#access-(permissions)-rest"
14315            ))
14316        );
14317    }
14318
14319    #[test]
14320    fn try_parse_link_url_no_parens() {
14321        let input = "[text](https://example.com)";
14322        let result = try_parse_link(input, 0);
14323        assert_eq!(result, Some((input.len(), "text", "https://example.com")));
14324    }
14325
14326    #[test]
14327    fn try_parse_link_url_with_multiple_nested_parens() {
14328        let input = "[x](http://en.wikipedia.org/wiki/Foo_(bar_(baz)))";
14329        let result = try_parse_link(input, 0);
14330        assert_eq!(
14331            result,
14332            Some((
14333                input.len(),
14334                "x",
14335                "http://en.wikipedia.org/wiki/Foo_(bar_(baz))"
14336            ))
14337        );
14338    }
14339
14340    #[test]
14341    fn parse_image_syntax_url_with_parens() {
14342        let result = parse_image_syntax("![alt](https://example.com/page_(1))");
14343        assert_eq!(result, Some(("alt", "https://example.com/page_(1)")));
14344    }
14345
14346    #[test]
14347    fn parse_image_syntax_url_no_parens() {
14348        let result = parse_image_syntax("![alt](https://example.com)");
14349        assert_eq!(result, Some(("alt", "https://example.com")));
14350    }
14351
14352    #[test]
14353    fn link_with_parens_round_trip() {
14354        let href = "https://example.com/faq#I-need-access-(permissions)-added-in-Monitor";
14355        let mut text_node = AdfNode::text("here");
14356        text_node.marks = Some(vec![AdfMark::link(href)]);
14357        let adf_input = AdfDocument {
14358            version: 1,
14359            doc_type: "doc".to_string(),
14360            content: vec![AdfNode::paragraph(vec![text_node])],
14361        };
14362
14363        let jfm = adf_to_markdown(&adf_input).unwrap();
14364        let adf_output = markdown_to_adf(&jfm).unwrap();
14365
14366        // Extract the href from the round-tripped ADF
14367        let para = &adf_output.content[0];
14368        let text_node = &para.content.as_ref().unwrap()[0];
14369        let mark = &text_node.marks.as_ref().unwrap()[0];
14370        let result_href = mark.attrs.as_ref().unwrap()["href"].as_str().unwrap();
14371
14372        assert_eq!(result_href, href);
14373    }
14374
14375    #[test]
14376    fn flush_plain_empty_range() {
14377        let mut nodes = Vec::new();
14378        flush_plain("hello", 3, 3, &mut nodes);
14379        assert!(nodes.is_empty());
14380    }
14381
14382    #[test]
14383    fn add_mark_to_unmarked_node() {
14384        let mut node = AdfNode::text("test");
14385        add_mark(&mut node, AdfMark::strong());
14386        assert_eq!(node.marks.as_ref().unwrap().len(), 1);
14387    }
14388
14389    #[test]
14390    fn add_mark_to_marked_node() {
14391        let mut node = AdfNode::text_with_marks("test", vec![AdfMark::strong()]);
14392        add_mark(&mut node, AdfMark::em());
14393        assert_eq!(node.marks.as_ref().unwrap().len(), 2);
14394    }
14395
14396    // ── Directive table tests ──────────────────────────────────────
14397
14398    #[test]
14399    fn directive_table_basic() {
14400        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";
14401        let doc = markdown_to_adf(md).unwrap();
14402        assert_eq!(doc.content[0].node_type, "table");
14403        let rows = doc.content[0].content.as_ref().unwrap();
14404        assert_eq!(rows.len(), 2);
14405        assert_eq!(
14406            rows[0].content.as_ref().unwrap()[0].node_type,
14407            "tableHeader"
14408        );
14409        assert_eq!(rows[1].content.as_ref().unwrap()[0].node_type, "tableCell");
14410    }
14411
14412    #[test]
14413    fn directive_table_with_block_content() {
14414        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";
14415        let doc = markdown_to_adf(md).unwrap();
14416        let rows = doc.content[0].content.as_ref().unwrap();
14417        let cell = &rows[0].content.as_ref().unwrap()[0];
14418        // Cell should have block content (paragraph + bullet list)
14419        let content = cell.content.as_ref().unwrap();
14420        assert!(content.len() >= 2);
14421        assert_eq!(content[1].node_type, "bulletList");
14422    }
14423
14424    #[test]
14425    fn directive_table_with_cell_attrs() {
14426        let md = "::::table\n:::tr\n:::td{colspan=2 bg=#DEEBFF}\nSpanning cell\n:::\n:::\n::::\n";
14427        let doc = markdown_to_adf(md).unwrap();
14428        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14429            .content
14430            .as_ref()
14431            .unwrap()[0];
14432        let attrs = cell.attrs.as_ref().unwrap();
14433        assert_eq!(attrs["colspan"], 2);
14434        assert_eq!(attrs["background"], "#DEEBFF");
14435    }
14436
14437    #[test]
14438    fn directive_table_with_css_var_background() {
14439        let bg = "var(--ds-background-accent-gray-subtlest, var(--ds-background-accent-gray-subtlest, #F1F2F4))";
14440        let md = format!("::::table\n:::tr\n:::th{{bg=\"{bg}\"}}\nHeader\n:::\n:::\n::::\n");
14441        let doc = markdown_to_adf(&md).unwrap();
14442        let row = &doc.content[0].content.as_ref().unwrap()[0];
14443        let cells = row.content.as_ref().unwrap();
14444        assert_eq!(cells.len(), 1, "row must have at least one cell");
14445        let attrs = cells[0].attrs.as_ref().unwrap();
14446        assert_eq!(attrs["background"], bg);
14447    }
14448
14449    #[test]
14450    fn css_var_background_round_trips() {
14451        let bg = "var(--ds-background-accent-gray-subtlest, #F1F2F4)";
14452        let adf = AdfDocument {
14453            version: 1,
14454            doc_type: "doc".to_string(),
14455            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14456                AdfNode::table_header_with_attrs(
14457                    vec![AdfNode::paragraph(vec![AdfNode::text("Header")])],
14458                    serde_json::json!({"background": bg}),
14459                ),
14460            ])])],
14461        };
14462        let md = adf_to_markdown(&adf).unwrap();
14463        assert!(
14464            md.contains(&format!("bg=\"{bg}\"")),
14465            "bg value must be quoted in markdown: {md}"
14466        );
14467
14468        let round_tripped = markdown_to_adf(&md).unwrap();
14469        let row = &round_tripped.content[0].content.as_ref().unwrap()[0];
14470        let cells = row.content.as_ref().unwrap();
14471        assert_eq!(cells.len(), 1, "round-tripped row must have one cell");
14472        let rt_attrs = cells[0].attrs.as_ref().unwrap();
14473        assert_eq!(rt_attrs["background"], bg);
14474    }
14475
14476    #[test]
14477    fn directive_table_with_table_attrs() {
14478        let md = "::::table{layout=wide numbered}\n:::tr\n:::td\nCell\n:::\n:::\n::::\n";
14479        let doc = markdown_to_adf(md).unwrap();
14480        let attrs = doc.content[0].attrs.as_ref().unwrap();
14481        assert_eq!(attrs["layout"], "wide");
14482        assert_eq!(attrs["isNumberColumnEnabled"], true);
14483    }
14484
14485    #[test]
14486    fn adf_table_with_block_content_renders_directive_form() {
14487        // Table with a bullet list in a cell → should render as ::::table directive
14488        let doc = AdfDocument {
14489            version: 1,
14490            doc_type: "doc".to_string(),
14491            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
14492                AdfNode::table_cell(vec![
14493                    AdfNode::paragraph(vec![AdfNode::text("Cell with list:")]),
14494                    AdfNode::bullet_list(vec![AdfNode::list_item(vec![AdfNode::paragraph(vec![
14495                        AdfNode::text("Item 1"),
14496                    ])])]),
14497                ]),
14498            ])])],
14499        };
14500        let md = adf_to_markdown(&doc).unwrap();
14501        assert!(md.contains("::::table"));
14502        assert!(md.contains(":::td"));
14503        assert!(md.contains("- Item 1"));
14504    }
14505
14506    #[test]
14507    fn adf_table_inline_only_renders_pipe_form() {
14508        // Table with only inline content → pipe table
14509        let doc = AdfDocument {
14510            version: 1,
14511            doc_type: "doc".to_string(),
14512            content: vec![AdfNode::table(vec![
14513                AdfNode::table_row(vec![
14514                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
14515                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14516                ]),
14517                AdfNode::table_row(vec![
14518                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
14519                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14520                ]),
14521            ])],
14522        };
14523        let md = adf_to_markdown(&doc).unwrap();
14524        assert!(md.contains("| H1 | H2 |"));
14525        assert!(!md.contains("::::table"));
14526    }
14527
14528    #[test]
14529    fn adf_table_header_outside_first_row_renders_directive() {
14530        let doc = AdfDocument {
14531            version: 1,
14532            doc_type: "doc".to_string(),
14533            content: vec![AdfNode::table(vec![
14534                AdfNode::table_row(vec![
14535                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H")])]),
14536                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C")])]),
14537                ]),
14538                AdfNode::table_row(vec![
14539                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
14540                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
14541                ]),
14542            ])],
14543        };
14544        let md = adf_to_markdown(&doc).unwrap();
14545        assert!(md.contains("::::table"));
14546        assert!(md.contains(":::th"));
14547    }
14548
14549    #[test]
14550    fn adf_table_cell_attrs_rendered() {
14551        let doc = AdfDocument {
14552            version: 1,
14553            doc_type: "doc".to_string(),
14554            content: vec![AdfNode::table(vec![
14555                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
14556                    AdfNode::text("H"),
14557                ])])]),
14558                AdfNode::table_row(vec![AdfNode::table_cell_with_attrs(
14559                    vec![AdfNode::paragraph(vec![AdfNode::text("C")])],
14560                    serde_json::json!({"background": "#DEEBFF", "colspan": 2}),
14561                )]),
14562            ])],
14563        };
14564        let md = adf_to_markdown(&doc).unwrap();
14565        assert!(md.contains("{colspan=2 bg=#DEEBFF}"));
14566    }
14567
14568    // ── Pipe table cell attrs tests ────────────────────────────────
14569
14570    #[test]
14571    fn pipe_table_cell_attrs() {
14572        let md = "| H1 | H2 |\n|---|---|\n| {bg=#DEEBFF} highlighted | normal |\n";
14573        let doc = markdown_to_adf(md).unwrap();
14574        let rows = doc.content[0].content.as_ref().unwrap();
14575        let cell = &rows[1].content.as_ref().unwrap()[0];
14576        let attrs = cell.attrs.as_ref().unwrap();
14577        assert_eq!(attrs["background"], "#DEEBFF");
14578    }
14579
14580    #[test]
14581    fn pipe_table_cell_colspan() {
14582        let md = "| H1 | H2 |\n|---|---|\n| {colspan=2} spanning |\n";
14583        let doc = markdown_to_adf(md).unwrap();
14584        let rows = doc.content[0].content.as_ref().unwrap();
14585        let cell = &rows[1].content.as_ref().unwrap()[0];
14586        let attrs = cell.attrs.as_ref().unwrap();
14587        assert_eq!(attrs["colspan"], 2);
14588    }
14589
14590    #[test]
14591    fn trailing_space_after_mention_in_table_cell_preserved() {
14592        // Issue #372: trailing space after mention in table cell was dropped
14593        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":[
14594          {"type":"mention","attrs":{"id":"aaa","text":"@Rob"}},
14595          {"type":"text","text":" "}
14596        ]}]}]}]}]}"#;
14597        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14598        let md = adf_to_markdown(&doc).unwrap();
14599        let round_tripped = markdown_to_adf(&md).unwrap();
14600        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14601            .content
14602            .as_ref()
14603            .unwrap()[0];
14604        let para = &cell.content.as_ref().unwrap()[0];
14605        let inlines = para.content.as_ref().unwrap();
14606        assert!(
14607            inlines.len() >= 2,
14608            "expected mention + text(' ') nodes, got {} nodes: {:?}",
14609            inlines.len(),
14610            inlines.iter().map(|n| &n.node_type).collect::<Vec<_>>()
14611        );
14612        assert_eq!(inlines[0].node_type, "mention");
14613        assert_eq!(inlines[1].node_type, "text");
14614        assert_eq!(inlines[1].text.as_deref(), Some(" "));
14615    }
14616
14617    // ── Column alignment tests ─────────────────────────────────────
14618
14619    #[test]
14620    fn pipe_table_column_alignment() {
14621        let md = "| Left | Center | Right |\n|:---|:---:|---:|\n| L | C | R |\n";
14622        let doc = markdown_to_adf(md).unwrap();
14623        let rows = doc.content[0].content.as_ref().unwrap();
14624        // Header row
14625        let h_cells = rows[0].content.as_ref().unwrap();
14626        // Left → no mark
14627        assert!(h_cells[0].content.as_ref().unwrap()[0].marks.is_none());
14628        // Center → alignment center
14629        let center_marks = h_cells[1].content.as_ref().unwrap()[0]
14630            .marks
14631            .as_ref()
14632            .unwrap();
14633        assert_eq!(center_marks[0].attrs.as_ref().unwrap()["align"], "center");
14634        // Right → alignment end
14635        let right_marks = h_cells[2].content.as_ref().unwrap()[0]
14636            .marks
14637            .as_ref()
14638            .unwrap();
14639        assert_eq!(right_marks[0].attrs.as_ref().unwrap()["align"], "end");
14640    }
14641
14642    #[test]
14643    fn adf_table_alignment_roundtrip() {
14644        let doc = AdfDocument {
14645            version: 1,
14646            doc_type: "doc".to_string(),
14647            content: vec![AdfNode::table(vec![
14648                AdfNode::table_row(vec![
14649                    AdfNode::table_header(vec![{
14650                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Center")]);
14651                        p.marks = Some(vec![AdfMark::alignment("center")]);
14652                        p
14653                    }]),
14654                    AdfNode::table_header(vec![{
14655                        let mut p = AdfNode::paragraph(vec![AdfNode::text("Right")]);
14656                        p.marks = Some(vec![AdfMark::alignment("end")]);
14657                        p
14658                    }]),
14659                ]),
14660                AdfNode::table_row(vec![
14661                    AdfNode::table_cell(vec![{
14662                        let mut p = AdfNode::paragraph(vec![AdfNode::text("C")]);
14663                        p.marks = Some(vec![AdfMark::alignment("center")]);
14664                        p
14665                    }]),
14666                    AdfNode::table_cell(vec![{
14667                        let mut p = AdfNode::paragraph(vec![AdfNode::text("R")]);
14668                        p.marks = Some(vec![AdfMark::alignment("end")]);
14669                        p
14670                    }]),
14671                ]),
14672            ])],
14673        };
14674        let md = adf_to_markdown(&doc).unwrap();
14675        assert!(md.contains(":---:"));
14676        assert!(md.contains("---:"));
14677    }
14678
14679    // ── Panel custom attrs tests ───────────────────────────────────
14680
14681    #[test]
14682    fn panel_custom_attrs_round_trip() {
14683        let md = ":::panel{type=custom icon=\":star:\" color=\"#DEEBFF\"}\nContent\n:::\n";
14684        let doc = markdown_to_adf(md).unwrap();
14685        let panel = &doc.content[0];
14686        let attrs = panel.attrs.as_ref().unwrap();
14687        assert_eq!(attrs["panelType"], "custom");
14688        assert_eq!(attrs["panelIcon"], ":star:");
14689        assert_eq!(attrs["panelColor"], "#DEEBFF");
14690
14691        let result = adf_to_markdown(&doc).unwrap();
14692        assert!(result.contains("type=custom"));
14693        assert!(result.contains("icon="));
14694        assert!(result.contains("color="));
14695    }
14696
14697    // ── Block card with attrs tests ────────────────────────────────
14698
14699    #[test]
14700    fn block_card_with_layout() {
14701        let md = "::card[https://example.com]{layout=wide}\n";
14702        let doc = markdown_to_adf(md).unwrap();
14703        let attrs = doc.content[0].attrs.as_ref().unwrap();
14704        assert_eq!(attrs["layout"], "wide");
14705
14706        let result = adf_to_markdown(&doc).unwrap();
14707        assert!(result.contains("::card[https://example.com]{layout=wide}"));
14708    }
14709
14710    // ── Extension params test ──────────────────────────────────────
14711
14712    #[test]
14713    fn extension_with_params() {
14714        let md = r#"::extension{type=com.atlassian.macro key=jira-chart params='{"jql":"project=PROJ"}'}"#;
14715        let doc = markdown_to_adf(&format!("{md}\n")).unwrap();
14716        let attrs = doc.content[0].attrs.as_ref().unwrap();
14717        assert_eq!(attrs["parameters"]["jql"], "project=PROJ");
14718    }
14719
14720    #[test]
14721    fn leaf_extension_layout_preserved_in_roundtrip() {
14722        // Issue #381: layout attr on extension nodes was dropped
14723        let adf_json = r#"{"version":1,"type":"doc","content":[
14724          {"type":"extension","attrs":{"extensionType":"com.atlassian.confluence.macro.core","extensionKey":"toc","layout":"default","parameters":{}}}
14725        ]}"#;
14726        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14727        let md = adf_to_markdown(&doc).unwrap();
14728        assert!(
14729            md.contains("layout=default"),
14730            "JFM should contain layout=default, got: {md}"
14731        );
14732        let round_tripped = markdown_to_adf(&md).unwrap();
14733        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14734        assert_eq!(attrs["layout"], "default", "layout should be preserved");
14735        assert_eq!(attrs["extensionKey"], "toc");
14736    }
14737
14738    #[test]
14739    fn bodied_extension_layout_preserved_in_roundtrip() {
14740        // Bodied extension with layout
14741        let adf_json = r#"{"version":1,"type":"doc","content":[
14742          {"type":"bodiedExtension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"expand","layout":"wide"},
14743           "content":[{"type":"paragraph","content":[{"type":"text","text":"inner"}]}]}
14744        ]}"#;
14745        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14746        let md = adf_to_markdown(&doc).unwrap();
14747        assert!(
14748            md.contains("layout=wide"),
14749            "JFM should contain layout=wide, got: {md}"
14750        );
14751        let round_tripped = markdown_to_adf(&md).unwrap();
14752        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14753        assert_eq!(attrs["layout"], "wide", "layout should be preserved");
14754    }
14755
14756    #[test]
14757    fn bodied_extension_parameters_preserved_in_roundtrip() {
14758        // Issue #473: parameters block inside bodiedExtension.attrs was dropped
14759        let adf_json = r#"{"version":1,"type":"doc","content":[
14760          {"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":{}}},
14761           "content":[{"type":"paragraph","content":[{"type":"text","text":"Content inside bodied extension"}]}]}
14762        ]}"#;
14763        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14764        let md = adf_to_markdown(&doc).unwrap();
14765        assert!(
14766            md.contains("params="),
14767            "JFM should contain params attribute, got: {md}"
14768        );
14769        let round_tripped = markdown_to_adf(&md).unwrap();
14770        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14771        assert_eq!(
14772            attrs["parameters"]["macroMetadata"]["title"], "Page Properties",
14773            "parameters should be preserved in round-trip"
14774        );
14775        assert_eq!(attrs["extensionKey"], "details");
14776        assert_eq!(attrs["layout"], "default");
14777        assert_eq!(attrs["localId"], "aabbccdd-1234");
14778    }
14779
14780    #[test]
14781    fn bodied_extension_malformed_params_ignored() {
14782        // Malformed params JSON should be silently ignored, not crash
14783        let md = ":::extension{type=com.atlassian.macro key=details params='not-valid-json'}\nContent\n:::\n";
14784        let doc = markdown_to_adf(md).unwrap();
14785        let attrs = doc.content[0].attrs.as_ref().unwrap();
14786        assert_eq!(attrs["extensionKey"], "details");
14787        // parameters should be absent since the JSON was invalid
14788        assert!(attrs.get("parameters").is_none());
14789    }
14790
14791    #[test]
14792    fn leaf_extension_localid_preserved_in_roundtrip() {
14793        // Extension with both layout and localId
14794        let adf_json = r#"{"version":1,"type":"doc","content":[
14795          {"type":"extension","attrs":{"extensionType":"com.atlassian.macro","extensionKey":"toc","layout":"default","localId":"abc-123"}}
14796        ]}"#;
14797        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14798        let md = adf_to_markdown(&doc).unwrap();
14799        let round_tripped = markdown_to_adf(&md).unwrap();
14800        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
14801        assert_eq!(attrs["layout"], "default");
14802        assert_eq!(attrs["localId"], "abc-123");
14803    }
14804
14805    // ── Mention with userType test ─────────────────────────────────
14806
14807    #[test]
14808    fn mention_with_user_type() {
14809        let md = "Hi :mention[Alice]{id=abc123 userType=DEFAULT}.\n";
14810        let doc = markdown_to_adf(md).unwrap();
14811        let mention = &doc.content[0].content.as_ref().unwrap()[1];
14812        assert_eq!(mention.attrs.as_ref().unwrap()["userType"], "DEFAULT");
14813
14814        let result = adf_to_markdown(&doc).unwrap();
14815        assert!(result.contains("userType=DEFAULT"));
14816    }
14817
14818    // ── Colwidth tests ─────────────────────────────────────────────
14819
14820    #[test]
14821    fn directive_table_colwidth() {
14822        let md = "::::table\n:::tr\n:::td{colwidth=100,200}\nCell\n:::\n:::\n::::\n";
14823        let doc = markdown_to_adf(md).unwrap();
14824        let cell = &doc.content[0].content.as_ref().unwrap()[0]
14825            .content
14826            .as_ref()
14827            .unwrap()[0];
14828        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14829        assert_eq!(colwidth, &[serde_json::json!(100), serde_json::json!(200)]);
14830    }
14831
14832    #[test]
14833    fn directive_table_colwidth_float_roundtrip() {
14834        // Confluence returns colwidth as floats (e.g. 157.0, 863.0).
14835        // adf_to_markdown must preserve them so markdown_to_adf can restore them.
14836        let adf_doc = serde_json::json!({
14837            "type": "doc",
14838            "version": 1,
14839            "content": [{
14840                "type": "table",
14841                "content": [{
14842                    "type": "tableRow",
14843                    "content": [
14844                        {
14845                            "type": "tableHeader",
14846                            "attrs": { "colwidth": [157.0] },
14847                            "content": [{ "type": "paragraph" }]
14848                        },
14849                        {
14850                            "type": "tableHeader",
14851                            "attrs": { "colwidth": [863.0] },
14852                            "content": [{ "type": "paragraph" }]
14853                        }
14854                    ]
14855                }]
14856            }]
14857        });
14858        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
14859        let md = adf_to_markdown(&doc).unwrap();
14860        assert!(
14861            md.contains("colwidth=157.0"),
14862            "expected colwidth=157.0 in markdown, got: {md}"
14863        );
14864        assert!(
14865            md.contains("colwidth=863.0"),
14866            "expected colwidth=863.0 in markdown, got: {md}"
14867        );
14868        // Round-trip back to ADF
14869        let doc2 = markdown_to_adf(&md).unwrap();
14870        let row = &doc2.content[0].content.as_ref().unwrap()[0];
14871        let header1 = &row.content.as_ref().unwrap()[0];
14872        let header2 = &row.content.as_ref().unwrap()[1];
14873        assert_eq!(
14874            header1.attrs.as_ref().unwrap()["colwidth"]
14875                .as_array()
14876                .unwrap(),
14877            &[serde_json::json!(157.0)]
14878        );
14879        assert_eq!(
14880            header2.attrs.as_ref().unwrap()["colwidth"]
14881                .as_array()
14882                .unwrap(),
14883            &[serde_json::json!(863.0)]
14884        );
14885    }
14886
14887    #[test]
14888    fn colwidth_float_preserved_in_roundtrip() {
14889        // Issue #369: colwidth 254.0 was coerced to integer 254
14890        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":[]}]}]}]}]}"#;
14891        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14892        let md = adf_to_markdown(&doc).unwrap();
14893        let round_tripped = markdown_to_adf(&md).unwrap();
14894        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14895            .content
14896            .as_ref()
14897            .unwrap()[0];
14898        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14899        assert_eq!(
14900            colwidth,
14901            &[serde_json::json!(254.0), serde_json::json!(416.0)],
14902            "colwidth should preserve float values"
14903        );
14904    }
14905
14906    #[test]
14907    fn colwidth_integer_preserved_in_roundtrip() {
14908        // Issue #459: colwidth integer values emitted as floats after round-trip
14909        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"}]}]}]}]}]}"#;
14910        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14911        let md = adf_to_markdown(&doc).unwrap();
14912        assert!(
14913            md.contains("colwidth=150"),
14914            "expected colwidth=150 (no decimal) in markdown, got: {md}"
14915        );
14916        assert!(
14917            !md.contains("colwidth=150.0"),
14918            "colwidth should not have .0 suffix for integers, got: {md}"
14919        );
14920        // Round-trip back to ADF
14921        let round_tripped = markdown_to_adf(&md).unwrap();
14922        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
14923            .content
14924            .as_ref()
14925            .unwrap()[0];
14926        let colwidth = cell.attrs.as_ref().unwrap()["colwidth"].as_array().unwrap();
14927        assert_eq!(
14928            colwidth,
14929            &[serde_json::json!(150)],
14930            "colwidth should preserve integer values"
14931        );
14932        // Verify JSON serialization uses integer, not float
14933        let json_output = serde_json::to_string(&round_tripped).unwrap();
14934        assert!(
14935            json_output.contains(r#""colwidth":[150]"#),
14936            "JSON should contain integer colwidth, got: {json_output}"
14937        );
14938    }
14939
14940    #[test]
14941    fn colwidth_mixed_int_and_float_roundtrip() {
14942        // Integer colwidth from standard ADF and float colwidth from Confluence
14943        // should each preserve their original type through round-trip.
14944        let int_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100,200]}}]}]}]}"#;
14945        let float_json = r#"{"version":1,"type":"doc","content":[{"type":"table","content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{"colwidth":[100.0,200.0]}}]}]}]}"#;
14946
14947        // Integer input → integer output
14948        let int_doc: AdfDocument = serde_json::from_str(int_json).unwrap();
14949        let int_md = adf_to_markdown(&int_doc).unwrap();
14950        assert!(
14951            int_md.contains("colwidth=100,200"),
14952            "integer colwidth in md: {int_md}"
14953        );
14954        let int_rt = markdown_to_adf(&int_md).unwrap();
14955        let int_serial = serde_json::to_string(&int_rt).unwrap();
14956        assert!(
14957            int_serial.contains(r#""colwidth":[100,200]"#),
14958            "integer colwidth in JSON: {int_serial}"
14959        );
14960
14961        // Float input → float output
14962        let float_doc: AdfDocument = serde_json::from_str(float_json).unwrap();
14963        let float_md = adf_to_markdown(&float_doc).unwrap();
14964        assert!(
14965            float_md.contains("colwidth=100.0,200.0"),
14966            "float colwidth in md: {float_md}"
14967        );
14968        let float_rt = markdown_to_adf(&float_md).unwrap();
14969        let float_serial = serde_json::to_string(&float_rt).unwrap();
14970        assert!(
14971            float_serial.contains(r#""colwidth":[100.0,200.0]"#),
14972            "float colwidth in JSON: {float_serial}"
14973        );
14974    }
14975
14976    #[test]
14977    fn colwidth_fractional_float_preserved() {
14978        // Covers the fractional-float branch (n.fract() != 0.0) in build_cell_attrs_string
14979        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"}]}]}]}]}]}"#;
14980        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
14981        let md = adf_to_markdown(&doc).unwrap();
14982        assert!(
14983            md.contains("colwidth=100.5"),
14984            "expected colwidth=100.5 in markdown, got: {md}"
14985        );
14986    }
14987
14988    #[test]
14989    fn colwidth_non_numeric_values_skipped() {
14990        // Covers the None branch for non-numeric colwidth entries in build_cell_attrs_string
14991        let adf_doc = serde_json::json!({
14992            "type": "doc",
14993            "version": 1,
14994            "content": [{
14995                "type": "table",
14996                "content": [{
14997                    "type": "tableRow",
14998                    "content": [{
14999                        "type": "tableCell",
15000                        "attrs": { "colwidth": ["invalid"] },
15001                        "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "cell" }] }]
15002                    }]
15003                }]
15004            }]
15005        });
15006        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15007        let md = adf_to_markdown(&doc).unwrap();
15008        // Non-numeric values are filtered out, so colwidth should not appear
15009        assert!(
15010            !md.contains("colwidth"),
15011            "non-numeric colwidth should be filtered out, got: {md}"
15012        );
15013    }
15014
15015    #[test]
15016    fn default_rowspan_colspan_preserved_in_roundtrip() {
15017        // Issue #369: rowspan=1 and colspan=1 were elided
15018        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"}]}]}]}]}]}"#;
15019        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15020        let md = adf_to_markdown(&doc).unwrap();
15021        let round_tripped = markdown_to_adf(&md).unwrap();
15022        let cell = &round_tripped.content[0].content.as_ref().unwrap()[0]
15023            .content
15024            .as_ref()
15025            .unwrap()[0];
15026        let attrs = cell.attrs.as_ref().unwrap();
15027        assert_eq!(attrs["rowspan"], 1, "rowspan=1 should be preserved");
15028        assert_eq!(attrs["colspan"], 1, "colspan=1 should be preserved");
15029    }
15030
15031    // ── Nested list tests ──────────────────────────────────────────────
15032
15033    #[test]
15034    fn table_localid_preserved_in_roundtrip() {
15035        // Issue #374: localId on table nodes was dropped
15036        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"}]}]}]}]}]}"#;
15037        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15038        let md = adf_to_markdown(&doc).unwrap();
15039        assert!(
15040            md.contains("localId="),
15041            "JFM should contain localId, got: {md}"
15042        );
15043        let round_tripped = markdown_to_adf(&md).unwrap();
15044        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15045        assert_eq!(
15046            attrs["localId"], "7afd4550-e66c-4b12-875f-a91c6c7b62c7",
15047            "localId should be preserved"
15048        );
15049    }
15050
15051    #[test]
15052    fn paragraph_localid_preserved_in_roundtrip() {
15053        // Issue #399: localId on paragraph nodes was dropped
15054        let adf_json = r#"{"version":1,"type":"doc","content":[
15055          {"type":"paragraph","attrs":{"localId":"abc-123"},"content":[{"type":"text","text":"hello"}]}
15056        ]}"#;
15057        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15058        let md = adf_to_markdown(&doc).unwrap();
15059        assert!(
15060            md.contains("localId=abc-123"),
15061            "JFM should contain localId, got: {md}"
15062        );
15063        let round_tripped = markdown_to_adf(&md).unwrap();
15064        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15065        assert_eq!(attrs["localId"], "abc-123", "localId should be preserved");
15066    }
15067
15068    #[test]
15069    fn heading_localid_preserved_in_roundtrip() {
15070        let adf_json = r#"{"version":1,"type":"doc","content":[
15071          {"type":"heading","attrs":{"level":2,"localId":"h-456"},"content":[{"type":"text","text":"Title"}]}
15072        ]}"#;
15073        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15074        let md = adf_to_markdown(&doc).unwrap();
15075        let round_tripped = markdown_to_adf(&md).unwrap();
15076        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15077        assert_eq!(attrs["localId"], "h-456");
15078    }
15079
15080    #[test]
15081    fn localid_with_alignment_preserved() {
15082        // localId and alignment marks should coexist in the same {attrs} block
15083        let adf_json = r#"{"version":1,"type":"doc","content":[
15084          {"type":"paragraph","attrs":{"localId":"p-789"},"marks":[{"type":"alignment","attrs":{"align":"center"}}],
15085           "content":[{"type":"text","text":"centered"}]}
15086        ]}"#;
15087        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15088        let md = adf_to_markdown(&doc).unwrap();
15089        assert!(md.contains("localId=p-789"), "should have localId: {md}");
15090        assert!(md.contains("align=center"), "should have align: {md}");
15091        let round_tripped = markdown_to_adf(&md).unwrap();
15092        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15093        assert_eq!(attrs["localId"], "p-789");
15094        let marks = round_tripped.content[0].marks.as_ref().unwrap();
15095        assert!(marks.iter().any(|m| m.mark_type == "alignment"));
15096    }
15097
15098    #[test]
15099    fn table_layout_default_preserved_in_roundtrip() {
15100        // Issue #380: layout='default' was elided
15101        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"}]}]}]}]}]}"#;
15102        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15103        let md = adf_to_markdown(&doc).unwrap();
15104        let round_tripped = markdown_to_adf(&md).unwrap();
15105        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15106        assert_eq!(
15107            attrs["layout"], "default",
15108            "layout='default' should be preserved"
15109        );
15110    }
15111
15112    #[test]
15113    fn table_is_number_column_enabled_false_preserved() {
15114        // Issue #380: isNumberColumnEnabled=false was elided
15115        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"}]}]}]}]}]}"#;
15116        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15117        let md = adf_to_markdown(&doc).unwrap();
15118        let round_tripped = markdown_to_adf(&md).unwrap();
15119        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15120        assert_eq!(
15121            attrs["isNumberColumnEnabled"], false,
15122            "isNumberColumnEnabled=false should be preserved"
15123        );
15124    }
15125
15126    #[test]
15127    fn table_is_number_column_enabled_true_preserved() {
15128        // Regression check: isNumberColumnEnabled=true should still work
15129        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"}]}]}]}]}]}"#;
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 attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15134        assert_eq!(
15135            attrs["isNumberColumnEnabled"], true,
15136            "isNumberColumnEnabled=true should be preserved"
15137        );
15138    }
15139
15140    #[test]
15141    fn directive_table_is_number_column_enabled_false_preserved() {
15142        // Covers render_directive_table + directive table parsing for numbered=false.
15143        // Multi-paragraph cell forces directive table form.
15144        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15145          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15146          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15147        ]}]}]}]}"#;
15148        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15149        let md = adf_to_markdown(&doc).unwrap();
15150        assert!(md.contains("::::table"), "should use directive table form");
15151        assert!(
15152            md.contains("numbered=false"),
15153            "should contain numbered=false, got: {md}"
15154        );
15155        let round_tripped = markdown_to_adf(&md).unwrap();
15156        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15157        assert_eq!(attrs["isNumberColumnEnabled"], false);
15158        assert_eq!(attrs["layout"], "default");
15159    }
15160
15161    #[test]
15162    fn directive_table_is_number_column_enabled_true_preserved() {
15163        // Covers render_directive_table + directive table parsing for numbered (true).
15164        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":true,"layout":"default"},"content":[{"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[
15165          {"type":"paragraph","content":[{"type":"text","text":"line one"}]},
15166          {"type":"paragraph","content":[{"type":"text","text":"line two"}]}
15167        ]}]}]}]}"#;
15168        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15169        let md = adf_to_markdown(&doc).unwrap();
15170        assert!(md.contains("::::table"), "should use directive table form");
15171        assert!(
15172            md.contains("numbered}") || md.contains("numbered "),
15173            "should contain numbered flag, got: {md}"
15174        );
15175        let round_tripped = markdown_to_adf(&md).unwrap();
15176        let attrs = round_tripped.content[0].attrs.as_ref().unwrap();
15177        assert_eq!(attrs["isNumberColumnEnabled"], true);
15178    }
15179
15180    #[test]
15181    fn trailing_space_in_bullet_list_item_preserved() {
15182        // Issue #394: trailing space text node in list item dropped
15183        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15184          {"type":"listItem","content":[{"type":"paragraph","content":[
15185            {"type":"text","text":"Before link "},
15186            {"type":"text","text":"link text","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15187            {"type":"text","text":" "}
15188          ]}]}
15189        ]}]}"#;
15190        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15191        let md = adf_to_markdown(&doc).unwrap();
15192        let round_tripped = markdown_to_adf(&md).unwrap();
15193        let list = &round_tripped.content[0];
15194        let item = &list.content.as_ref().unwrap()[0];
15195        let para = &item.content.as_ref().unwrap()[0];
15196        let inlines = para.content.as_ref().unwrap();
15197        let last = inlines.last().unwrap();
15198        assert_eq!(
15199            last.text.as_deref(),
15200            Some(" "),
15201            "trailing space text node should be preserved, got nodes: {:?}",
15202            inlines
15203                .iter()
15204                .map(|n| (&n.node_type, &n.text))
15205                .collect::<Vec<_>>()
15206        );
15207    }
15208
15209    #[test]
15210    fn trailing_space_after_mention_in_bullet_list_preserved() {
15211        // Mention + trailing space in list item
15212        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
15213          {"type":"listItem","content":[{"type":"paragraph","content":[
15214            {"type":"mention","attrs":{"id":"abc","text":"@Alice"}},
15215            {"type":"text","text":" "}
15216          ]}]}
15217        ]}]}"#;
15218        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15219        let md = adf_to_markdown(&doc).unwrap();
15220        let round_tripped = markdown_to_adf(&md).unwrap();
15221        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15222            .content
15223            .as_ref()
15224            .unwrap()[0];
15225        let inlines = para.content.as_ref().unwrap();
15226        assert!(
15227            inlines.len() >= 2,
15228            "should have mention + trailing space, got {} nodes",
15229            inlines.len()
15230        );
15231        assert_eq!(inlines.last().unwrap().text.as_deref(), Some(" "));
15232    }
15233
15234    #[test]
15235    fn trailing_space_in_ordered_list_item_preserved() {
15236        // Same issue in ordered list context
15237        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15238          {"type":"listItem","content":[{"type":"paragraph","content":[
15239            {"type":"text","text":"item "},
15240            {"type":"text","text":"link","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
15241            {"type":"text","text":" "}
15242          ]}]}
15243        ]}]}"#;
15244        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15245        let md = adf_to_markdown(&doc).unwrap();
15246        let round_tripped = markdown_to_adf(&md).unwrap();
15247        let para = &round_tripped.content[0].content.as_ref().unwrap()[0]
15248            .content
15249            .as_ref()
15250            .unwrap()[0];
15251        let inlines = para.content.as_ref().unwrap();
15252        let last = inlines.last().unwrap();
15253        assert_eq!(
15254            last.text.as_deref(),
15255            Some(" "),
15256            "trailing space should be preserved in ordered list item"
15257        );
15258    }
15259
15260    #[test]
15261    fn trailing_space_in_heading_text_preserved() {
15262        // Issue #400: trailing space in heading text node trimmed on round-trip
15263        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[
15264          {"type":"text","text":"Firefighting Engineers "}
15265        ]}]}"#;
15266        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15267        let md = adf_to_markdown(&doc).unwrap();
15268        let round_tripped = markdown_to_adf(&md).unwrap();
15269        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15270        assert_eq!(
15271            inlines[0].text.as_deref(),
15272            Some("Firefighting Engineers "),
15273            "trailing space in heading should be preserved"
15274        );
15275    }
15276
15277    #[test]
15278    fn trailing_space_in_heading_before_bold_preserved() {
15279        // Issue #400: trailing space before bold sibling in heading
15280        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15281          {"type":"text","text":"Classic "},
15282          {"type":"text","text":"bold","marks":[{"type":"strong"}]}
15283        ]}]}"#;
15284        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15285        let md = adf_to_markdown(&doc).unwrap();
15286        let round_tripped = markdown_to_adf(&md).unwrap();
15287        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15288        assert_eq!(
15289            inlines[0].text.as_deref(),
15290            Some("Classic "),
15291            "trailing space in heading text before bold should be preserved"
15292        );
15293    }
15294
15295    #[test]
15296    fn leading_space_in_heading_text_preserved() {
15297        // Issue #492: leading spaces in heading text node stripped on round-trip
15298        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":3},"content":[
15299          {"type":"text","text":"  #general-channel"}
15300        ]}]}"#;
15301        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15302        let md = adf_to_markdown(&doc).unwrap();
15303        let round_tripped = markdown_to_adf(&md).unwrap();
15304        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15305        assert_eq!(
15306            inlines[0].text.as_deref(),
15307            Some("  #general-channel"),
15308            "leading spaces in heading text should be preserved"
15309        );
15310    }
15311
15312    #[test]
15313    fn leading_space_in_heading_before_bold_preserved() {
15314        // Issue #492: leading space before bold sibling in heading
15315        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[
15316          {"type":"text","text":"   indented"},
15317          {"type":"text","text":" bold","marks":[{"type":"strong"}]}
15318        ]}]}"#;
15319        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15320        let md = adf_to_markdown(&doc).unwrap();
15321        let round_tripped = markdown_to_adf(&md).unwrap();
15322        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15323        assert_eq!(
15324            inlines[0].text.as_deref(),
15325            Some("   indented"),
15326            "leading spaces in heading text before bold should be preserved"
15327        );
15328    }
15329
15330    #[test]
15331    fn heading_multiple_leading_spaces_markdown_parse() {
15332        // Issue #492: verify JFM parsing preserves leading spaces
15333        let md = "### \t  #general-channel";
15334        let doc = markdown_to_adf(md).unwrap();
15335        let inlines = doc.content[0].content.as_ref().unwrap();
15336        assert_eq!(
15337            inlines[0].text.as_deref(),
15338            Some("\t  #general-channel"),
15339            "leading whitespace in heading text should be preserved during JFM parsing"
15340        );
15341    }
15342
15343    #[test]
15344    fn trailing_space_in_paragraph_text_preserved() {
15345        // Issue #400: trailing space in paragraph text node preserved on round-trip
15346        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
15347          {"type":"text","text":"word followed by space "},
15348          {"type":"text","text":"next node","marks":[{"type":"strong"}]}
15349        ]}]}"#;
15350        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15351        let md = adf_to_markdown(&doc).unwrap();
15352        let round_tripped = markdown_to_adf(&md).unwrap();
15353        let inlines = round_tripped.content[0].content.as_ref().unwrap();
15354        assert_eq!(
15355            inlines[0].text.as_deref(),
15356            Some("word followed by space "),
15357            "trailing space in paragraph text should be preserved"
15358        );
15359    }
15360
15361    #[test]
15362    fn nested_bullet_list_roundtrip() {
15363        // ADF with a listItem containing a paragraph + nested bulletList
15364        let adf_doc = serde_json::json!({
15365            "type": "doc",
15366            "version": 1,
15367            "content": [{
15368                "type": "bulletList",
15369                "content": [{
15370                    "type": "listItem",
15371                    "content": [
15372                        {
15373                            "type": "paragraph",
15374                            "content": [{"type": "text", "text": "parent item"}]
15375                        },
15376                        {
15377                            "type": "bulletList",
15378                            "content": [
15379                                {
15380                                    "type": "listItem",
15381                                    "content": [{
15382                                        "type": "paragraph",
15383                                        "content": [{"type": "text", "text": "sub item 1"}]
15384                                    }]
15385                                },
15386                                {
15387                                    "type": "listItem",
15388                                    "content": [{
15389                                        "type": "paragraph",
15390                                        "content": [{"type": "text", "text": "sub item 2"}]
15391                                    }]
15392                                }
15393                            ]
15394                        }
15395                    ]
15396                }]
15397            }]
15398        });
15399        let doc: AdfDocument = serde_json::from_value(adf_doc).unwrap();
15400        let md = adf_to_markdown(&doc).unwrap();
15401        assert!(
15402            md.contains("- parent item\n"),
15403            "expected top-level item in markdown, got: {md}"
15404        );
15405        assert!(
15406            md.contains("  - sub item 1\n"),
15407            "expected indented sub item 1 in markdown, got: {md}"
15408        );
15409        assert!(
15410            md.contains("  - sub item 2\n"),
15411            "expected indented sub item 2 in markdown, got: {md}"
15412        );
15413
15414        // Round-trip back
15415        let doc2 = markdown_to_adf(&md).unwrap();
15416        let list = &doc2.content[0];
15417        assert_eq!(list.node_type, "bulletList");
15418        let item = &list.content.as_ref().unwrap()[0];
15419        assert_eq!(item.node_type, "listItem");
15420        let item_content = item.content.as_ref().unwrap();
15421        assert_eq!(
15422            item_content.len(),
15423            2,
15424            "listItem should have paragraph + nested list"
15425        );
15426        assert_eq!(item_content[0].node_type, "paragraph");
15427        assert_eq!(item_content[1].node_type, "bulletList");
15428        let sub_items = item_content[1].content.as_ref().unwrap();
15429        assert_eq!(sub_items.len(), 2);
15430    }
15431
15432    #[test]
15433    fn nested_bullet_in_table_cell_roundtrip() {
15434        let md = "::::table\n:::tr\n:::td\n- parent\n  - child\n:::\n:::\n::::\n";
15435        let doc = markdown_to_adf(md).unwrap();
15436        let table = &doc.content[0];
15437        let row = &table.content.as_ref().unwrap()[0];
15438        let cell = &row.content.as_ref().unwrap()[0];
15439        let list = &cell.content.as_ref().unwrap()[0];
15440        assert_eq!(list.node_type, "bulletList");
15441        let item = &list.content.as_ref().unwrap()[0];
15442        let item_content = item.content.as_ref().unwrap();
15443        assert_eq!(
15444            item_content.len(),
15445            2,
15446            "listItem should have paragraph + nested list"
15447        );
15448        assert_eq!(item_content[1].node_type, "bulletList");
15449
15450        // Round-trip: adf→md→adf should preserve the nested list
15451        let md2 = adf_to_markdown(&doc).unwrap();
15452        assert!(
15453            md2.contains("  - child"),
15454            "expected indented child in round-tripped markdown, got: {md2}"
15455        );
15456    }
15457
15458    #[test]
15459    fn nested_ordered_list_roundtrip() {
15460        // Issue #389: nested orderedList inside listItem flattened
15461        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
15462          {"type":"listItem","content":[
15463            {"type":"paragraph","content":[{"type":"text","text":"Top level"}]},
15464            {"type":"orderedList","attrs":{"order":1},"content":[
15465              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 1"}]}]},
15466              {"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"Nested 2"}]}]}
15467            ]}
15468          ]},
15469          {"type":"listItem","content":[
15470            {"type":"paragraph","content":[{"type":"text","text":"Second top"}]}
15471          ]}
15472        ]}]}"#;
15473        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15474        let md = adf_to_markdown(&doc).unwrap();
15475        let round_tripped = markdown_to_adf(&md).unwrap();
15476
15477        // Outer list should have 2 items
15478        let outer = &round_tripped.content[0];
15479        assert_eq!(outer.node_type, "orderedList");
15480        assert_eq!(
15481            outer.attrs.as_ref().unwrap()["order"],
15482            1,
15483            "explicit order=1 must be preserved via trailing {{order=1}} (issue #547)"
15484        );
15485        let outer_items = outer.content.as_ref().unwrap();
15486        assert_eq!(
15487            outer_items.len(),
15488            2,
15489            "outer list should have 2 items, got {}",
15490            outer_items.len()
15491        );
15492
15493        // First item should have paragraph + nested orderedList
15494        let first_item = &outer_items[0];
15495        let first_content = first_item.content.as_ref().unwrap();
15496        assert_eq!(
15497            first_content.len(),
15498            2,
15499            "first listItem should have paragraph + nested list, got {}",
15500            first_content.len()
15501        );
15502        assert_eq!(first_content[0].node_type, "paragraph");
15503        assert_eq!(first_content[1].node_type, "orderedList");
15504        let nested_items = first_content[1].content.as_ref().unwrap();
15505        assert_eq!(nested_items.len(), 2, "nested list should have 2 items");
15506    }
15507
15508    #[test]
15509    fn nested_ordered_list_markdown_parsing() {
15510        // Direct markdown parsing of nested ordered list
15511        let md = "1. Top level\n  1. Nested 1\n  2. Nested 2\n2. Second top\n";
15512        let doc = markdown_to_adf(md).unwrap();
15513        let outer = &doc.content[0];
15514        assert_eq!(outer.node_type, "orderedList");
15515        let outer_items = outer.content.as_ref().unwrap();
15516        assert_eq!(outer_items.len(), 2, "should have 2 top-level items");
15517
15518        let first_content = outer_items[0].content.as_ref().unwrap();
15519        assert_eq!(
15520            first_content.len(),
15521            2,
15522            "first item should have paragraph + nested list"
15523        );
15524        assert_eq!(first_content[1].node_type, "orderedList");
15525    }
15526
15527    #[test]
15528    fn bullet_list_nested_inside_ordered_list() {
15529        // Mixed nesting: bullet list nested inside ordered list
15530        let md = "1. Ordered item\n  - Bullet child 1\n  - Bullet child 2\n2. Second ordered\n";
15531        let doc = markdown_to_adf(md).unwrap();
15532        let outer = &doc.content[0];
15533        assert_eq!(outer.node_type, "orderedList");
15534        let outer_items = outer.content.as_ref().unwrap();
15535        assert_eq!(outer_items.len(), 2);
15536
15537        let first_content = outer_items[0].content.as_ref().unwrap();
15538        assert_eq!(
15539            first_content.len(),
15540            2,
15541            "first item should have paragraph + nested list"
15542        );
15543        assert_eq!(first_content[1].node_type, "bulletList");
15544        let sub_items = first_content[1].content.as_ref().unwrap();
15545        assert_eq!(sub_items.len(), 2, "nested bullet list should have 2 items");
15546    }
15547
15548    #[test]
15549    fn ordered_list_order_attr_one_is_elided() {
15550        // Issue #547: order=1 is the default and must be elided from attrs
15551        // for round-trip fidelity with ADF documents that omit the attrs
15552        // object on orderedList.
15553        let md = "1. A\n2. B\n";
15554        let doc = markdown_to_adf(md).unwrap();
15555        assert!(
15556            doc.content[0].attrs.is_none(),
15557            "attrs should be elided when order=1"
15558        );
15559
15560        // Round-trip should preserve the elision
15561        let md2 = adf_to_markdown(&doc).unwrap();
15562        let doc2 = markdown_to_adf(&md2).unwrap();
15563        assert!(
15564            doc2.content[0].attrs.is_none(),
15565            "attrs should remain elided after round-trip"
15566        );
15567    }
15568
15569    #[test]
15570    fn issue_547_ordered_list_no_attrs_roundtrip_byte_identical() {
15571        // Issue #547: ADF orderedList without an attrs field must round-trip
15572        // (ADF → JFM → ADF) without gaining a spurious {"order": 1} attrs.
15573        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"}]}]}]}]}"#;
15574        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15575        let md = adf_to_markdown(&doc).unwrap();
15576        let rt = markdown_to_adf(&md).unwrap();
15577        assert!(
15578            rt.content[0].attrs.is_none(),
15579            "round-tripped orderedList should not have attrs, got: {:?}",
15580            rt.content[0].attrs
15581        );
15582
15583        // Serialized JSON must also omit attrs entirely for byte fidelity.
15584        let rt_json = serde_json::to_string(&rt).unwrap();
15585        assert!(
15586            !rt_json.contains("\"order\""),
15587            "round-tripped JSON should not contain \"order\", got: {rt_json}"
15588        );
15589    }
15590
15591    // ── Issue #547: orderedList byte-identical roundtrip coverage ───────
15592
15593    /// Assert that ADF → JFM → ADF produces a document whose serialized JSON
15594    /// (as a sorted-key canonical form) matches the source JSON. Mirrors the
15595    /// `jq --sort-keys` comparison used in the issue's reproducer.
15596    fn assert_roundtrip_byte_identical(adf_json: &str) {
15597        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15598        let md = adf_to_markdown(&doc).unwrap();
15599        let rt = markdown_to_adf(&md).unwrap();
15600
15601        let canonical_src: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15602        let canonical_rt: serde_json::Value =
15603            serde_json::from_str(&serde_json::to_string(&rt).unwrap()).unwrap();
15604        assert_eq!(
15605            canonical_src, canonical_rt,
15606            "round-trip diverged\n  src: {canonical_src}\n   rt: {canonical_rt}\n   md: {md:?}"
15607        );
15608    }
15609
15610    #[test]
15611    fn issue_547_single_item_no_attrs_roundtrip() {
15612        assert_roundtrip_byte_identical(
15613            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"only"}]}]}]}]}"#,
15614        );
15615    }
15616
15617    #[test]
15618    fn issue_547_many_items_no_attrs_roundtrip() {
15619        assert_roundtrip_byte_identical(
15620            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"}]}]}]}]}"#,
15621        );
15622    }
15623
15624    #[test]
15625    fn issue_547_non_default_order_preserved() {
15626        // When order != 1, attrs must still be serialized (fix must not
15627        // over-eagerly drop attrs).
15628        assert_roundtrip_byte_identical(
15629            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":5},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"fifth"}]}]}]}]}"#,
15630        );
15631    }
15632
15633    #[test]
15634    fn issue_547_nested_ordered_in_ordered_no_attrs_roundtrip() {
15635        // Outer and inner both omit attrs; fix must apply at every level.
15636        assert_roundtrip_byte_identical(
15637            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"}]}]}]}]}]}]}"#,
15638        );
15639    }
15640
15641    #[test]
15642    fn issue_547_ordered_nested_in_bullet_no_attrs_roundtrip() {
15643        assert_roundtrip_byte_identical(
15644            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"}]}]}]}]}]}]}"#,
15645        );
15646    }
15647
15648    #[test]
15649    fn issue_547_bullet_nested_in_ordered_no_attrs_roundtrip() {
15650        assert_roundtrip_byte_identical(
15651            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"}]}]}]}]}]}]}"#,
15652        );
15653    }
15654
15655    #[test]
15656    fn issue_547_ordered_list_between_paragraphs_roundtrip() {
15657        assert_roundtrip_byte_identical(
15658            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"}]}]}"#,
15659        );
15660    }
15661
15662    #[test]
15663    fn issue_547_ordered_list_with_marked_text_roundtrip() {
15664        assert_roundtrip_byte_identical(
15665            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"bold","marks":[{"type":"strong"}]}]}]}]}]}"#,
15666        );
15667    }
15668
15669    #[test]
15670    fn issue_547_ordered_list_with_link_roundtrip() {
15671        assert_roundtrip_byte_identical(
15672            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"}}]}]}]}]}]}"#,
15673        );
15674    }
15675
15676    #[test]
15677    fn issue_547_ordered_list_with_hardbreak_roundtrip() {
15678        assert_roundtrip_byte_identical(
15679            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"}]}]}]}]}"#,
15680        );
15681    }
15682
15683    #[test]
15684    fn issue_547_triple_nested_ordered_roundtrip() {
15685        assert_roundtrip_byte_identical(
15686            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"}]}]}]}]}]}]}]}]}"#,
15687        );
15688    }
15689
15690    #[test]
15691    fn issue_547_ordered_list_heading_rule_mix_roundtrip() {
15692        assert_roundtrip_byte_identical(
15693            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"}]}"#,
15694        );
15695    }
15696
15697    #[test]
15698    fn issue_547_ordered_list_listitem_localid_roundtrip() {
15699        // listItem attrs must coexist with the no-attrs outer orderedList.
15700        assert_roundtrip_byte_identical(
15701            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","attrs":{"localId":"li-001"},"content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]}]}]}"#,
15702        );
15703    }
15704
15705    #[test]
15706    fn issue_547_explicit_order_one_preserved_roundtrip() {
15707        // Inverse regression (see PR #562 comment 4266630848): when the source
15708        // ADF has an explicit `"attrs": {"order": 1}` the round-trip must
15709        // preserve it, not strip it. A trailing `{order=1}` signal on the
15710        // rendered markdown distinguishes explicit-default from omitted attrs.
15711        assert_roundtrip_byte_identical(
15712            r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"First item"}]}]}]}]}"#,
15713        );
15714    }
15715
15716    #[test]
15717    fn issue_547_explicit_order_one_nested_preserved_roundtrip() {
15718        // Both outer and inner orderedList have explicit `order: 1`; both must
15719        // be preserved across the round-trip independently.
15720        assert_roundtrip_byte_identical(
15721            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"}]}]}]}]}]}]}"#,
15722        );
15723    }
15724
15725    #[test]
15726    fn issue_547_mixed_explicit_and_implicit_order_roundtrip() {
15727        // Sibling orderedLists with different attrs presence must round-trip
15728        // independently: first has explicit `order: 1`, second omits attrs.
15729        assert_roundtrip_byte_identical(
15730            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"}]}]}]}]}"#,
15731        );
15732    }
15733
15734    #[test]
15735    fn issue_547_explicit_order_one_with_listitem_localid_roundtrip() {
15736        // Explicit `order: 1` outer, plus a listItem `localId` inside — the
15737        // trailing `{order=1}` line must not swallow or collide with listItem
15738        // attrs.
15739        assert_roundtrip_byte_identical(
15740            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"}]}]}]}]}"#,
15741        );
15742    }
15743
15744    #[test]
15745    fn issue_547_order_attr_signal_appears_only_for_explicit_one() {
15746        // Render-layer guard: `{order=1}` appears in markdown only when the
15747        // source ADF has explicit `attrs.order=1`. No signal for attrs=None,
15748        // no signal for attrs.order>1 (marker already encodes the value).
15749        let no_attrs = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"x"}]}]}]}]}"#;
15750        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"}]}]}]}]}"#;
15751        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"}]}]}]}]}"#;
15752
15753        let md_no =
15754            adf_to_markdown(&serde_json::from_str::<AdfDocument>(no_attrs).unwrap()).unwrap();
15755        let md_one =
15756            adf_to_markdown(&serde_json::from_str::<AdfDocument>(explicit_one).unwrap()).unwrap();
15757        let md_five =
15758            adf_to_markdown(&serde_json::from_str::<AdfDocument>(order_five).unwrap()).unwrap();
15759
15760        assert!(
15761            !md_no.contains("{order="),
15762            "no-attrs source must not emit order signal, got: {md_no:?}"
15763        );
15764        assert!(
15765            md_one.contains("{order=1}"),
15766            "explicit order=1 must emit trailing signal, got: {md_one:?}"
15767        );
15768        assert!(
15769            !md_five.contains("{order="),
15770            "order=5 is already encoded by marker; must not emit signal, got: {md_five:?}"
15771        );
15772    }
15773
15774    // ── File media round-trip tests ─────────────────────────────────────
15775
15776    #[test]
15777    fn file_media_roundtrip() {
15778        // ADF with a Confluence file attachment (type:file media)
15779        let adf_doc = serde_json::json!({
15780            "type": "doc",
15781            "version": 1,
15782            "content": [{
15783                "type": "mediaSingle",
15784                "attrs": {"layout": "center"},
15785                "content": [{
15786                    "type": "media",
15787                    "attrs": {
15788                        "type": "file",
15789                        "id": "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d",
15790                        "collection": "contentId-8220672100",
15791                        "height": 56,
15792                        "width": 312,
15793                        "alt": "Screenshot.png"
15794                    }
15795                }]
15796            }]
15797        });
15798        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
15799        let md = adf_to_markdown(&doc).unwrap();
15800        assert!(
15801            md.contains("type=file"),
15802            "expected type=file in markdown, got: {md}"
15803        );
15804        assert!(
15805            md.contains("id=6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d"),
15806            "expected id in markdown, got: {md}"
15807        );
15808        assert!(
15809            md.contains("collection=contentId-8220672100"),
15810            "expected collection in markdown, got: {md}"
15811        );
15812        // Round-trip back to ADF
15813        let doc2 = markdown_to_adf(&md).unwrap();
15814        let ms = &doc2.content[0];
15815        assert_eq!(ms.node_type, "mediaSingle");
15816        let media = &ms.content.as_ref().unwrap()[0];
15817        assert_eq!(media.node_type, "media");
15818        let attrs = media.attrs.as_ref().unwrap();
15819        assert_eq!(attrs["type"], "file");
15820        assert_eq!(attrs["id"], "6e8ebc85-81a3-4b4c-865a-ec4dd8978c2d");
15821        assert_eq!(attrs["collection"], "contentId-8220672100");
15822        assert_eq!(attrs["height"], 56);
15823        assert_eq!(attrs["width"], 312);
15824        assert_eq!(attrs["alt"], "Screenshot.png");
15825    }
15826
15827    /// Issue #550: roundtrip of mediaSingle with file-type media preserves all
15828    /// file attributes (type, id, collection, width, height). Regression guard
15829    /// for the exact reproducer in the issue body.
15830    #[test]
15831    fn file_media_roundtrip_issue_550_reproducer() {
15832        let adf_json = r#"{
15833          "version": 1,
15834          "type": "doc",
15835          "content": [
15836            {
15837              "type": "mediaSingle",
15838              "attrs": {"layout": "center"},
15839              "content": [
15840                {
15841                  "type": "media",
15842                  "attrs": {
15843                    "type": "file",
15844                    "id": "abc-123-def-456",
15845                    "collection": "my-collection",
15846                    "width": 941,
15847                    "height": 655
15848                  }
15849                }
15850              ]
15851            }
15852          ]
15853        }"#;
15854        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15855        let md = adf_to_markdown(&doc).unwrap();
15856        let rt = markdown_to_adf(&md).unwrap();
15857        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15858        let actual = serde_json::to_value(&rt).unwrap();
15859        assert_eq!(
15860            actual, expected,
15861            "roundtrip should preserve file media attrs; md was:\n{md}"
15862        );
15863    }
15864
15865    /// Issue #550 (updated reproducer): roundtrip of a file-media `id`
15866    /// containing spaces must not truncate the value. Before the fix, the
15867    /// JFM renderer emitted `id=abc 123 def 456` unquoted and the parser
15868    /// treated the first space as a value terminator, so the `id` became
15869    /// `"abc"` after round-trip.
15870    #[test]
15871    fn file_media_roundtrip_id_with_spaces() {
15872        let adf_json = r#"{
15873          "version": 1,
15874          "type": "doc",
15875          "content": [
15876            {
15877              "type": "mediaSingle",
15878              "attrs": {"layout": "center"},
15879              "content": [
15880                {
15881                  "type": "media",
15882                  "attrs": {
15883                    "type": "file",
15884                    "id": "abc 123 def 456",
15885                    "collection": "my-collection",
15886                    "width": 800,
15887                    "height": 600
15888                  }
15889                }
15890              ]
15891            }
15892          ]
15893        }"#;
15894        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15895        let md = adf_to_markdown(&doc).unwrap();
15896        assert!(
15897            md.contains(r#"id="abc 123 def 456""#),
15898            "id with spaces should be quoted in JFM, got:\n{md}"
15899        );
15900        let rt = markdown_to_adf(&md).unwrap();
15901        let expected: serde_json::Value = serde_json::from_str(adf_json).unwrap();
15902        let actual = serde_json::to_value(&rt).unwrap();
15903        assert_eq!(
15904            actual, expected,
15905            "space-containing id must round-trip; md was:\n{md}"
15906        );
15907    }
15908
15909    /// Space-containing `collection` values must round-trip.
15910    #[test]
15911    fn file_media_roundtrip_collection_with_spaces() {
15912        let adf_json = r#"{
15913          "version": 1,
15914          "type": "doc",
15915          "content": [
15916            {
15917              "type": "mediaSingle",
15918              "attrs": {"layout": "center"},
15919              "content": [
15920                {
15921                  "type": "media",
15922                  "attrs": {
15923                    "type": "file",
15924                    "id": "abc-123",
15925                    "collection": "my collection with spaces"
15926                  }
15927                }
15928              ]
15929            }
15930          ]
15931        }"#;
15932        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15933        let md = adf_to_markdown(&doc).unwrap();
15934        let rt = markdown_to_adf(&md).unwrap();
15935        let media = &rt.content[0].content.as_ref().unwrap()[0];
15936        assert_eq!(
15937            media.attrs.as_ref().unwrap()["collection"],
15938            "my collection with spaces"
15939        );
15940    }
15941
15942    /// Space-containing `occurrenceKey` values must round-trip.
15943    #[test]
15944    fn file_media_roundtrip_occurrence_key_with_spaces() {
15945        let adf_json = r#"{
15946          "version": 1,
15947          "type": "doc",
15948          "content": [
15949            {
15950              "type": "mediaSingle",
15951              "attrs": {"layout": "center"},
15952              "content": [
15953                {
15954                  "type": "media",
15955                  "attrs": {
15956                    "type": "file",
15957                    "id": "x",
15958                    "collection": "y",
15959                    "occurrenceKey": "key with spaces"
15960                  }
15961                }
15962              ]
15963            }
15964          ]
15965        }"#;
15966        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
15967        let md = adf_to_markdown(&doc).unwrap();
15968        let rt = markdown_to_adf(&md).unwrap();
15969        let media = &rt.content[0].content.as_ref().unwrap()[0];
15970        assert_eq!(
15971            media.attrs.as_ref().unwrap()["occurrenceKey"],
15972            "key with spaces"
15973        );
15974    }
15975
15976    /// Values with embedded `"` must be escape-quoted and round-trip.
15977    #[test]
15978    fn file_media_roundtrip_id_with_quote_char() {
15979        let adf_json = r#"{
15980          "version": 1,
15981          "type": "doc",
15982          "content": [
15983            {
15984              "type": "mediaSingle",
15985              "attrs": {"layout": "center"},
15986              "content": [
15987                {
15988                  "type": "media",
15989                  "attrs": {
15990                    "type": "file",
15991                    "id": "a\"b\"c",
15992                    "collection": "col"
15993                  }
15994                }
15995              ]
15996            }
15997          ]
15998        }"#;
15999        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16000        let md = adf_to_markdown(&doc).unwrap();
16001        let rt = markdown_to_adf(&md).unwrap();
16002        let media = &rt.content[0].content.as_ref().unwrap()[0];
16003        assert_eq!(media.attrs.as_ref().unwrap()["id"], "a\"b\"c");
16004    }
16005
16006    /// `mediaInline` string attrs with spaces must round-trip (parallel fix
16007    /// for the inline-directive rendering path).
16008    #[test]
16009    fn media_inline_roundtrip_id_with_spaces() {
16010        let adf_json = r#"{
16011          "version": 1,
16012          "type": "doc",
16013          "content": [
16014            {
16015              "type": "paragraph",
16016              "content": [
16017                {"type": "text", "text": "before "},
16018                {
16019                  "type": "mediaInline",
16020                  "attrs": {
16021                    "type": "file",
16022                    "id": "a b c",
16023                    "collection": "my col"
16024                  }
16025                },
16026                {"type": "text", "text": " after"}
16027              ]
16028            }
16029          ]
16030        }"#;
16031        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16032        let md = adf_to_markdown(&doc).unwrap();
16033        let rt = markdown_to_adf(&md).unwrap();
16034        let inline = &rt.content[0].content.as_ref().unwrap()[1];
16035        assert_eq!(inline.node_type, "mediaInline");
16036        let attrs = inline.attrs.as_ref().unwrap();
16037        assert_eq!(attrs["id"], "a b c");
16038        assert_eq!(attrs["collection"], "my col");
16039    }
16040
16041    /// Issue #550: `occurrenceKey` attribute is a standard ADF media attr and
16042    /// must be preserved through ADF→JFM→ADF roundtrip.
16043    #[test]
16044    fn file_media_roundtrip_preserves_occurrence_key() {
16045        let adf_json = r#"{
16046          "version": 1,
16047          "type": "doc",
16048          "content": [
16049            {
16050              "type": "mediaSingle",
16051              "attrs": {"layout": "center"},
16052              "content": [
16053                {
16054                  "type": "media",
16055                  "attrs": {
16056                    "type": "file",
16057                    "id": "abc-123",
16058                    "collection": "my-collection",
16059                    "occurrenceKey": "unique-key-xyz",
16060                    "width": 200,
16061                    "height": 100
16062                  }
16063                }
16064              ]
16065            }
16066          ]
16067        }"#;
16068        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16069        let md = adf_to_markdown(&doc).unwrap();
16070        assert!(
16071            md.contains("occurrenceKey=unique-key-xyz"),
16072            "expected occurrenceKey in markdown, got: {md}"
16073        );
16074        let rt = markdown_to_adf(&md).unwrap();
16075        let media = &rt.content[0].content.as_ref().unwrap()[0];
16076        let attrs = media.attrs.as_ref().unwrap();
16077        assert_eq!(attrs["occurrenceKey"], "unique-key-xyz");
16078        assert_eq!(attrs["type"], "file");
16079        assert_eq!(attrs["id"], "abc-123");
16080        assert_eq!(attrs["collection"], "my-collection");
16081    }
16082
16083    // ── mediaSingle caption tests (issue #470) ──────────────────────────
16084
16085    #[test]
16086    fn media_single_caption_adf_to_markdown() {
16087        let adf_doc = serde_json::json!({
16088            "type": "doc",
16089            "version": 1,
16090            "content": [{
16091                "type": "mediaSingle",
16092                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16093                "content": [
16094                    {
16095                        "type": "media",
16096                        "attrs": {
16097                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16098                            "type": "file",
16099                            "collection": "contentId-123456",
16100                            "width": 800,
16101                            "height": 600
16102                        }
16103                    },
16104                    {
16105                        "type": "caption",
16106                        "content": [{"type": "text", "text": "An image caption here"}]
16107                    }
16108                ]
16109            }]
16110        });
16111        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16112        let md = adf_to_markdown(&doc).unwrap();
16113        assert!(
16114            md.contains(":::caption"),
16115            "expected :::caption in markdown, got: {md}"
16116        );
16117        assert!(
16118            md.contains("An image caption here"),
16119            "expected caption text in markdown, got: {md}"
16120        );
16121    }
16122
16123    #[test]
16124    fn media_single_caption_markdown_to_adf() {
16125        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nAn image caption here\n:::\n";
16126        let doc = markdown_to_adf(md).unwrap();
16127        let ms = &doc.content[0];
16128        assert_eq!(ms.node_type, "mediaSingle");
16129        let content = ms.content.as_ref().unwrap();
16130        assert_eq!(content.len(), 2, "expected media + caption children");
16131        assert_eq!(content[0].node_type, "media");
16132        assert_eq!(content[1].node_type, "caption");
16133        let caption_content = content[1].content.as_ref().unwrap();
16134        assert_eq!(
16135            caption_content[0].text.as_deref(),
16136            Some("An image caption here")
16137        );
16138    }
16139
16140    #[test]
16141    fn media_single_caption_round_trip() {
16142        // Full round-trip: ADF → JFM → ADF preserves caption
16143        let adf_doc = serde_json::json!({
16144            "type": "doc",
16145            "version": 1,
16146            "content": [{
16147                "type": "mediaSingle",
16148                "attrs": {"layout": "center", "width": 400, "widthType": "pixel"},
16149                "content": [
16150                    {
16151                        "type": "media",
16152                        "attrs": {
16153                            "id": "aabbccdd-1234-5678-abcd-aabbccdd1234",
16154                            "type": "file",
16155                            "collection": "contentId-123456",
16156                            "width": 800,
16157                            "height": 600
16158                        }
16159                    },
16160                    {
16161                        "type": "caption",
16162                        "content": [{"type": "text", "text": "An image caption here"}]
16163                    }
16164                ]
16165            }]
16166        });
16167        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16168        let md = adf_to_markdown(&doc).unwrap();
16169        let doc2 = markdown_to_adf(&md).unwrap();
16170        let ms = &doc2.content[0];
16171        assert_eq!(ms.node_type, "mediaSingle");
16172        let content = ms.content.as_ref().unwrap();
16173        assert_eq!(
16174            content.len(),
16175            2,
16176            "expected media + caption after round-trip"
16177        );
16178        assert_eq!(content[1].node_type, "caption");
16179        let caption_content = content[1].content.as_ref().unwrap();
16180        assert_eq!(
16181            caption_content[0].text.as_deref(),
16182            Some("An image caption here")
16183        );
16184    }
16185
16186    #[test]
16187    fn media_single_caption_with_inline_marks() {
16188        let adf_doc = serde_json::json!({
16189            "type": "doc",
16190            "version": 1,
16191            "content": [{
16192                "type": "mediaSingle",
16193                "attrs": {"layout": "center"},
16194                "content": [
16195                    {
16196                        "type": "media",
16197                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16198                    },
16199                    {
16200                        "type": "caption",
16201                        "content": [
16202                            {"type": "text", "text": "A "},
16203                            {"type": "text", "text": "bold", "marks": [{"type": "strong"}]},
16204                            {"type": "text", "text": " caption"}
16205                        ]
16206                    }
16207                ]
16208            }]
16209        });
16210        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16211        let md = adf_to_markdown(&doc).unwrap();
16212        assert!(
16213            md.contains("**bold**"),
16214            "expected bold in caption, got: {md}"
16215        );
16216
16217        let doc2 = markdown_to_adf(&md).unwrap();
16218        let content = doc2.content[0].content.as_ref().unwrap();
16219        assert_eq!(content.len(), 2, "expected media + caption");
16220        assert_eq!(content[1].node_type, "caption");
16221        let caption_inlines = content[1].content.as_ref().unwrap();
16222        let bold_node = caption_inlines
16223            .iter()
16224            .find(|n| n.text.as_deref() == Some("bold"))
16225            .unwrap();
16226        let marks = bold_node.marks.as_ref().unwrap();
16227        assert_eq!(marks[0].mark_type, "strong");
16228    }
16229
16230    #[test]
16231    fn media_single_no_caption_unaffected() {
16232        // Existing mediaSingle without caption should be unaffected
16233        let adf_doc = serde_json::json!({
16234            "type": "doc",
16235            "version": 1,
16236            "content": [{
16237                "type": "mediaSingle",
16238                "attrs": {"layout": "center"},
16239                "content": [{
16240                    "type": "media",
16241                    "attrs": {"type": "external", "url": "https://example.com/img.png"}
16242                }]
16243            }]
16244        });
16245        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16246        let md = adf_to_markdown(&doc).unwrap();
16247        assert!(
16248            !md.contains(":::caption"),
16249            "should not emit caption when none present"
16250        );
16251        let doc2 = markdown_to_adf(&md).unwrap();
16252        let content = doc2.content[0].content.as_ref().unwrap();
16253        assert_eq!(content.len(), 1, "should only have media child");
16254        assert_eq!(content[0].node_type, "media");
16255    }
16256
16257    #[test]
16258    fn media_single_empty_caption_round_trip() {
16259        // Caption node with no content should still round-trip
16260        let adf_doc = serde_json::json!({
16261            "type": "doc",
16262            "version": 1,
16263            "content": [{
16264                "type": "mediaSingle",
16265                "attrs": {"layout": "center"},
16266                "content": [
16267                    {
16268                        "type": "media",
16269                        "attrs": {"type": "external", "url": "https://example.com/img.png"}
16270                    },
16271                    {
16272                        "type": "caption"
16273                    }
16274                ]
16275            }]
16276        });
16277        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16278        let md = adf_to_markdown(&doc).unwrap();
16279        assert!(
16280            md.contains(":::caption"),
16281            "expected :::caption even for empty caption, got: {md}"
16282        );
16283        assert!(
16284            md.contains(":::\n"),
16285            "expected closing ::: fence, got: {md}"
16286        );
16287    }
16288
16289    #[test]
16290    fn media_single_external_caption_round_trip() {
16291        // External image with caption round-trips
16292        let md = "![alt](https://example.com/img.png)\n:::caption\nImage description\n:::\n";
16293        let doc = markdown_to_adf(md).unwrap();
16294        let ms = &doc.content[0];
16295        assert_eq!(ms.node_type, "mediaSingle");
16296        let content = ms.content.as_ref().unwrap();
16297        assert_eq!(content.len(), 2);
16298        assert_eq!(content[0].node_type, "media");
16299        assert_eq!(content[1].node_type, "caption");
16300
16301        let md2 = adf_to_markdown(&doc).unwrap();
16302        let doc2 = markdown_to_adf(&md2).unwrap();
16303        let content2 = doc2.content[0].content.as_ref().unwrap();
16304        assert_eq!(content2.len(), 2);
16305        assert_eq!(content2[1].node_type, "caption");
16306        let caption_text = content2[1].content.as_ref().unwrap();
16307        assert_eq!(caption_text[0].text.as_deref(), Some("Image description"));
16308    }
16309
16310    // ── mediaSingle caption localId tests (issue #524) ─────────────────
16311
16312    #[test]
16313    fn media_single_caption_localid_roundtrip() {
16314        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"}]}]}]}"#;
16315        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16316        let md = adf_to_markdown(&doc).unwrap();
16317        assert!(
16318            md.contains("localId=9da8c2104471"),
16319            "caption localId should appear in markdown: {md}"
16320        );
16321        let rt = markdown_to_adf(&md).unwrap();
16322        let content = rt.content[0].content.as_ref().unwrap();
16323        let caption = &content[1];
16324        assert_eq!(caption.node_type, "caption");
16325        assert_eq!(
16326            caption.attrs.as_ref().unwrap()["localId"],
16327            "9da8c2104471",
16328            "caption localId should round-trip"
16329        );
16330    }
16331
16332    #[test]
16333    fn media_single_caption_without_localid() {
16334        let md = "![Screenshot](){type=file id=abc-123 collection=contentId-456 height=600 width=800}\n:::caption\nPlain caption\n:::\n";
16335        let doc = markdown_to_adf(md).unwrap();
16336        let caption = &doc.content[0].content.as_ref().unwrap()[1];
16337        assert_eq!(caption.node_type, "caption");
16338        assert!(
16339            caption.attrs.is_none(),
16340            "caption without localId should not gain attrs"
16341        );
16342        let md2 = adf_to_markdown(&doc).unwrap();
16343        assert!(
16344            !md2.contains("localId"),
16345            "no localId should appear in output: {md2}"
16346        );
16347    }
16348
16349    #[test]
16350    fn media_single_caption_localid_stripped_when_option_set() {
16351        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"}]}]}]}"#;
16352        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16353        let opts = RenderOptions {
16354            strip_local_ids: true,
16355            ..Default::default()
16356        };
16357        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16358        assert!(!md.contains("localId"), "localId should be stripped: {md}");
16359    }
16360
16361    #[test]
16362    fn table_width_roundtrip() {
16363        // ADF table with width attribute
16364        let adf_doc = serde_json::json!({
16365            "type": "doc",
16366            "version": 1,
16367            "content": [{
16368                "type": "table",
16369                "attrs": {"layout": "default", "width": 760.0},
16370                "content": [{
16371                    "type": "tableRow",
16372                    "content": [{
16373                        "type": "tableHeader",
16374                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16375                    }]
16376                }]
16377            }]
16378        });
16379        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16380        let md = adf_to_markdown(&doc).unwrap();
16381        assert!(
16382            md.contains("width=760.0"),
16383            "expected width=760.0 in markdown (float preserved), got: {md}"
16384        );
16385        // Round-trip back to ADF
16386        let doc2 = markdown_to_adf(&md).unwrap();
16387        let table = &doc2.content[0];
16388        assert_eq!(table.node_type, "table");
16389        let table_attrs = table.attrs.as_ref().unwrap();
16390        assert_eq!(table_attrs["width"], 760.0);
16391        assert!(
16392            table_attrs["width"].is_f64(),
16393            "expected float width to be preserved as f64, got: {:?}",
16394            table_attrs["width"]
16395        );
16396    }
16397
16398    #[test]
16399    fn table_integer_width_roundtrip_preserves_integer() {
16400        // Issue #577: Integer width in ADF must survive roundtrip without being
16401        // coerced to a float.
16402        let adf_doc = serde_json::json!({
16403            "type": "doc",
16404            "version": 1,
16405            "content": [{
16406                "type": "table",
16407                "attrs": {
16408                    "isNumberColumnEnabled": false,
16409                    "layout": "center",
16410                    "localId": "abc-123",
16411                    "width": 1420
16412                },
16413                "content": [{
16414                    "type": "tableRow",
16415                    "content": [{
16416                        "type": "tableCell",
16417                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Cell"}]}]
16418                    }]
16419                }]
16420            }]
16421        });
16422        let doc: crate::atlassian::adf::AdfDocument =
16423            serde_json::from_value(adf_doc.clone()).unwrap();
16424        let md = adf_to_markdown(&doc).unwrap();
16425        assert!(
16426            md.contains("width=1420"),
16427            "expected width=1420 in markdown, got: {md}"
16428        );
16429        assert!(
16430            !md.contains("width=1420.0"),
16431            "integer width should not be rendered with decimal: {md}"
16432        );
16433
16434        let doc2 = markdown_to_adf(&md).unwrap();
16435        let table = &doc2.content[0];
16436        assert_eq!(table.node_type, "table");
16437        let table_attrs = table.attrs.as_ref().unwrap();
16438        assert_eq!(table_attrs["width"], 1420);
16439        assert!(
16440            table_attrs["width"].is_u64() || table_attrs["width"].is_i64(),
16441            "width should remain an integer, got: {:?}",
16442            table_attrs["width"]
16443        );
16444        assert!(
16445            !table_attrs["width"].is_f64(),
16446            "width should not be a float, got: {:?}",
16447            table_attrs["width"]
16448        );
16449
16450        // Full byte-fidelity: re-serialized ADF should match original JSON.
16451        let roundtripped = serde_json::to_value(&doc2).unwrap();
16452        let orig_width = &adf_doc["content"][0]["attrs"]["width"];
16453        let rt_width = &roundtripped["content"][0]["attrs"]["width"];
16454        assert_eq!(
16455            orig_width, rt_width,
16456            "width value must roundtrip byte-for-byte"
16457        );
16458    }
16459
16460    #[test]
16461    fn table_fractional_width_roundtrip() {
16462        // Fractional float widths should also roundtrip faithfully.
16463        let adf_doc = serde_json::json!({
16464            "type": "doc",
16465            "version": 1,
16466            "content": [{
16467                "type": "table",
16468                "attrs": {"layout": "default", "width": 760.5},
16469                "content": [{
16470                    "type": "tableRow",
16471                    "content": [{
16472                        "type": "tableHeader",
16473                        "content": [{"type": "paragraph", "content": [{"type": "text", "text": "H"}]}]
16474                    }]
16475                }]
16476            }]
16477        });
16478        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16479        let md = adf_to_markdown(&doc).unwrap();
16480        assert!(
16481            md.contains("width=760.5"),
16482            "expected width=760.5 in markdown, got: {md}"
16483        );
16484        let doc2 = markdown_to_adf(&md).unwrap();
16485        let table_attrs = doc2.content[0].attrs.as_ref().unwrap();
16486        assert_eq!(table_attrs["width"], 760.5);
16487        assert!(table_attrs["width"].is_f64());
16488    }
16489
16490    #[test]
16491    fn pipe_table_integer_width_roundtrip() {
16492        // Exercises the try_table() attrs-on-next-line parsing path.
16493        let md = "| A | B |\n|---|---|\n| 1 | 2 |\n{layout=default width=1420}\n";
16494        let doc = markdown_to_adf(md).unwrap();
16495        let table = &doc.content[0];
16496        assert_eq!(table.node_type, "table");
16497        let attrs = table.attrs.as_ref().unwrap();
16498        assert_eq!(attrs["width"], 1420);
16499        assert!(
16500            attrs["width"].is_u64() || attrs["width"].is_i64(),
16501            "pipe-table width must stay integer, got: {:?}",
16502            attrs["width"]
16503        );
16504    }
16505
16506    #[test]
16507    fn file_media_width_type_roundtrip() {
16508        // mediaSingle with widthType:pixel should survive round-trip
16509        let adf_doc = serde_json::json!({
16510            "type": "doc",
16511            "version": 1,
16512            "content": [{
16513                "type": "mediaSingle",
16514                "attrs": {"layout": "center", "width": 312, "widthType": "pixel"},
16515                "content": [{
16516                    "type": "media",
16517                    "attrs": {
16518                        "type": "file",
16519                        "id": "abc123",
16520                        "collection": "contentId-999",
16521                        "height": 56,
16522                        "width": 312
16523                    }
16524                }]
16525            }]
16526        });
16527        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16528        let md = adf_to_markdown(&doc).unwrap();
16529        assert!(
16530            md.contains("widthType=pixel"),
16531            "expected widthType=pixel in markdown, got: {md}"
16532        );
16533        let doc2 = markdown_to_adf(&md).unwrap();
16534        let ms = &doc2.content[0];
16535        let ms_attrs = ms.attrs.as_ref().unwrap();
16536        assert_eq!(ms_attrs["widthType"], "pixel");
16537        assert_eq!(ms_attrs["width"], 312);
16538    }
16539
16540    #[test]
16541    fn file_media_pixel_width_above_100_passes_validation() {
16542        // Issue #1037: confluence_read emits pixel widths well above 100 for
16543        // editor-sized images (here width=900). Lowering carries width=900
16544        // widthType=pixel onto the mediaSingle, which is valid ADF — the
16545        // upstream schema's pixel branch is unbounded. A read→write round-trip
16546        // must clear the write-path validator, not be rejected as out of range
16547        // (the original failure: "value 900 is outside the allowed range
16548        // [0, 100]"). Unlike `file_media_width_type_roundtrip`, this asserts on
16549        // the validator, which the conversion-only round-trip never exercises.
16550        let md = "![alt](){type=file id=11111111-2222-3333-4444-555555555555 collection=contentId-98765 height=904 width=900 mediaWidth=900 widthType=pixel}\n";
16551        let adf = markdown_to_adf(md).unwrap();
16552
16553        let ms_attrs = adf.content[0].attrs.as_ref().unwrap();
16554        assert_eq!(ms_attrs["width"], 900);
16555        assert_eq!(ms_attrs["widthType"], "pixel");
16556
16557        crate::atlassian::adf_validated::validate(&adf).unwrap();
16558    }
16559
16560    #[test]
16561    fn file_media_mode_roundtrip() {
16562        // mediaSingle with mode attr should survive round-trip (issue #431)
16563        let adf_doc = serde_json::json!({
16564            "type": "doc",
16565            "version": 1,
16566            "content": [{
16567                "type": "mediaSingle",
16568                "attrs": {"layout": "wide", "mode": "wide", "width": 1200},
16569                "content": [{
16570                    "type": "media",
16571                    "attrs": {
16572                        "type": "file",
16573                        "id": "abc123",
16574                        "collection": "test",
16575                        "width": 1200,
16576                        "height": 600
16577                    }
16578                }]
16579            }]
16580        });
16581        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16582        let md = adf_to_markdown(&doc).unwrap();
16583        assert!(
16584            md.contains("mode=wide"),
16585            "expected mode=wide in markdown, got: {md}"
16586        );
16587        let doc2 = markdown_to_adf(&md).unwrap();
16588        let ms = &doc2.content[0];
16589        let ms_attrs = ms.attrs.as_ref().unwrap();
16590        assert_eq!(ms_attrs["mode"], "wide");
16591        assert_eq!(ms_attrs["layout"], "wide");
16592        assert_eq!(ms_attrs["width"], 1200);
16593    }
16594
16595    #[test]
16596    fn external_media_mode_roundtrip() {
16597        // External mediaSingle with mode attr should survive round-trip (issue #431)
16598        let adf_doc = serde_json::json!({
16599            "type": "doc",
16600            "version": 1,
16601            "content": [{
16602                "type": "mediaSingle",
16603                "attrs": {"layout": "wide", "mode": "wide"},
16604                "content": [{
16605                    "type": "media",
16606                    "attrs": {
16607                        "type": "external",
16608                        "url": "https://example.com/image.png"
16609                    }
16610                }]
16611            }]
16612        });
16613        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16614        let md = adf_to_markdown(&doc).unwrap();
16615        assert!(
16616            md.contains("mode=wide"),
16617            "expected mode=wide in markdown, got: {md}"
16618        );
16619        let doc2 = markdown_to_adf(&md).unwrap();
16620        let ms = &doc2.content[0];
16621        let ms_attrs = ms.attrs.as_ref().unwrap();
16622        assert_eq!(ms_attrs["mode"], "wide");
16623        assert_eq!(ms_attrs["layout"], "wide");
16624    }
16625
16626    #[test]
16627    fn media_mode_only_roundtrip() {
16628        // mediaSingle with mode but default layout should still preserve mode (issue #431)
16629        let adf_doc = serde_json::json!({
16630            "type": "doc",
16631            "version": 1,
16632            "content": [{
16633                "type": "mediaSingle",
16634                "attrs": {"layout": "center", "mode": "default"},
16635                "content": [{
16636                    "type": "media",
16637                    "attrs": {
16638                        "type": "external",
16639                        "url": "https://example.com/image.png"
16640                    }
16641                }]
16642            }]
16643        });
16644        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16645        let md = adf_to_markdown(&doc).unwrap();
16646        assert!(
16647            md.contains("mode=default"),
16648            "expected mode=default in markdown, got: {md}"
16649        );
16650        let doc2 = markdown_to_adf(&md).unwrap();
16651        let ms = &doc2.content[0];
16652        let ms_attrs = ms.attrs.as_ref().unwrap();
16653        assert_eq!(ms_attrs["mode"], "default");
16654    }
16655
16656    #[test]
16657    fn file_media_hex_localid_roundtrip() {
16658        // Issue #432: short hex localId (non-UUID) must survive round-trip
16659        let adf_doc = serde_json::json!({
16660            "type": "doc",
16661            "version": 1,
16662            "content": [{
16663                "type": "mediaSingle",
16664                "attrs": {"layout": "wide", "width": 1200, "widthType": "pixel"},
16665                "content": [{
16666                    "type": "media",
16667                    "attrs": {
16668                        "type": "file",
16669                        "id": "eb7a9c3b-314e-4458-8200-4b22b67b122e",
16670                        "collection": "contentId-123",
16671                        "height": 484,
16672                        "width": 915,
16673                        "alt": "image.png",
16674                        "localId": "0e79f58ac382"
16675                    }
16676                }]
16677            }]
16678        });
16679        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16680        let md = adf_to_markdown(&doc).unwrap();
16681        assert!(
16682            md.contains("localId=0e79f58ac382"),
16683            "expected localId=0e79f58ac382 in markdown, got: {md}"
16684        );
16685        let doc2 = markdown_to_adf(&md).unwrap();
16686        let ms = &doc2.content[0];
16687        let media = &ms.content.as_ref().unwrap()[0];
16688        let attrs = media.attrs.as_ref().unwrap();
16689        assert_eq!(attrs["localId"], "0e79f58ac382");
16690    }
16691
16692    #[test]
16693    fn file_media_uuid_localid_roundtrip() {
16694        // UUID-format localId must also survive round-trip
16695        let adf_doc = serde_json::json!({
16696            "type": "doc",
16697            "version": 1,
16698            "content": [{
16699                "type": "mediaSingle",
16700                "attrs": {"layout": "center"},
16701                "content": [{
16702                    "type": "media",
16703                    "attrs": {
16704                        "type": "file",
16705                        "id": "abc-123",
16706                        "collection": "contentId-456",
16707                        "height": 100,
16708                        "width": 200,
16709                        "localId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
16710                    }
16711                }]
16712            }]
16713        });
16714        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16715        let md = adf_to_markdown(&doc).unwrap();
16716        assert!(
16717            md.contains("localId=a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
16718            "expected UUID localId in markdown, got: {md}"
16719        );
16720        let doc2 = markdown_to_adf(&md).unwrap();
16721        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16722        let attrs = media.attrs.as_ref().unwrap();
16723        assert_eq!(attrs["localId"], "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
16724    }
16725
16726    #[test]
16727    fn file_media_null_uuid_localid_stripped() {
16728        // Null UUID localId should be stripped (consistent with other node types)
16729        let adf_doc = serde_json::json!({
16730            "type": "doc",
16731            "version": 1,
16732            "content": [{
16733                "type": "mediaSingle",
16734                "attrs": {"layout": "center"},
16735                "content": [{
16736                    "type": "media",
16737                    "attrs": {
16738                        "type": "file",
16739                        "id": "abc-123",
16740                        "collection": "contentId-456",
16741                        "height": 100,
16742                        "width": 200,
16743                        "localId": "00000000-0000-0000-0000-000000000000"
16744                    }
16745                }]
16746            }]
16747        });
16748        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16749        let md = adf_to_markdown(&doc).unwrap();
16750        assert!(
16751            !md.contains("localId="),
16752            "null UUID localId should be stripped, got: {md}"
16753        );
16754    }
16755
16756    #[test]
16757    fn file_media_localid_stripped_when_option_set() {
16758        // localId should be stripped when strip_local_ids option is enabled
16759        let adf_doc = serde_json::json!({
16760            "type": "doc",
16761            "version": 1,
16762            "content": [{
16763                "type": "mediaSingle",
16764                "attrs": {"layout": "center"},
16765                "content": [{
16766                    "type": "media",
16767                    "attrs": {
16768                        "type": "file",
16769                        "id": "abc-123",
16770                        "collection": "contentId-456",
16771                        "height": 100,
16772                        "width": 200,
16773                        "localId": "0e79f58ac382"
16774                    }
16775                }]
16776            }]
16777        });
16778        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16779        let opts = RenderOptions {
16780            strip_local_ids: true,
16781            ..Default::default()
16782        };
16783        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
16784        assert!(
16785            !md.contains("localId="),
16786            "localId should be stripped with strip_local_ids, got: {md}"
16787        );
16788    }
16789
16790    #[test]
16791    fn external_media_localid_roundtrip() {
16792        // localId on external media nodes must also survive round-trip
16793        let adf_doc = serde_json::json!({
16794            "type": "doc",
16795            "version": 1,
16796            "content": [{
16797                "type": "mediaSingle",
16798                "attrs": {"layout": "center"},
16799                "content": [{
16800                    "type": "media",
16801                    "attrs": {
16802                        "type": "external",
16803                        "url": "https://example.com/image.png",
16804                        "alt": "test",
16805                        "localId": "deadbeef1234"
16806                    }
16807                }]
16808            }]
16809        });
16810        let doc: crate::atlassian::adf::AdfDocument = serde_json::from_value(adf_doc).unwrap();
16811        let md = adf_to_markdown(&doc).unwrap();
16812        assert!(
16813            md.contains("localId=deadbeef1234"),
16814            "expected localId in markdown for external media, got: {md}"
16815        );
16816        let doc2 = markdown_to_adf(&md).unwrap();
16817        let media = &doc2.content[0].content.as_ref().unwrap()[0];
16818        let attrs = media.attrs.as_ref().unwrap();
16819        assert_eq!(attrs["localId"], "deadbeef1234");
16820    }
16821
16822    #[test]
16823    fn bracket_in_text_not_parsed_as_link() {
16824        // "[Task] some text (Link)" — the [Task] must NOT be treated as a link anchor
16825        let md = ":check_mark: [Task] Unable to start trial ([Link](https://example.com/link))";
16826        let doc = markdown_to_adf(md).unwrap();
16827        let para = &doc.content[0];
16828        assert_eq!(para.node_type, "paragraph");
16829        let content = para.content.as_ref().unwrap();
16830        // Find the text node containing "[Task]"
16831        let text_nodes: Vec<_> = content.iter().filter(|n| n.node_type == "text").collect();
16832        let has_task_bracket = text_nodes
16833            .iter()
16834            .any(|n| n.text.as_deref().unwrap_or("").contains("[Task]"));
16835        assert!(
16836            has_task_bracket,
16837            "expected [Task] in plain text, nodes: {content:?}"
16838        );
16839        // Also verify the (Link) is a proper link
16840        let link_nodes: Vec<_> = content
16841            .iter()
16842            .filter(|n| {
16843                n.marks
16844                    .as_ref()
16845                    .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "link"))
16846            })
16847            .collect();
16848        assert!(!link_nodes.is_empty(), "expected a link node");
16849        assert_eq!(
16850            link_nodes[0].text.as_deref(),
16851            Some("Link"),
16852            "link text should be 'Link'"
16853        );
16854    }
16855
16856    #[test]
16857    fn empty_paragraph_roundtrip() {
16858        // An empty ADF paragraph node should survive a round-trip through markdown
16859        let mut adf_in = AdfDocument::new();
16860        adf_in.content = vec![
16861            AdfNode::paragraph(vec![AdfNode::text("before")]),
16862            AdfNode::paragraph(vec![]),
16863            AdfNode::paragraph(vec![AdfNode::text("after")]),
16864        ];
16865        let md = adf_to_markdown(&adf_in).unwrap();
16866        let adf_out = markdown_to_adf(&md).unwrap();
16867        assert_eq!(
16868            adf_out.content.len(),
16869            3,
16870            "should have 3 blocks, markdown:\n{md}"
16871        );
16872        assert_eq!(adf_out.content[0].node_type, "paragraph");
16873        assert_eq!(adf_out.content[1].node_type, "paragraph");
16874        assert!(
16875            adf_out.content[1].content.is_none(),
16876            "middle paragraph should be empty"
16877        );
16878        assert_eq!(adf_out.content[2].node_type, "paragraph");
16879    }
16880
16881    #[test]
16882    fn nbsp_paragraph_roundtrip() {
16883        // Issue #411: paragraph with only NBSP should survive round-trip
16884        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}";
16885        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16886        let md = adf_to_markdown(&doc).unwrap();
16887        assert!(
16888            md.contains("::paragraph["),
16889            "NBSP paragraph should use directive form: {md}"
16890        );
16891        let rt = markdown_to_adf(&md).unwrap();
16892        assert_eq!(rt.content.len(), 1, "should have 1 block");
16893        assert_eq!(rt.content[0].node_type, "paragraph");
16894        let text = rt.content[0].content.as_ref().unwrap()[0]
16895            .text
16896            .as_deref()
16897            .unwrap_or("");
16898        assert_eq!(text, "\u{00a0}", "NBSP should survive round-trip");
16899    }
16900
16901    #[test]
16902    fn nbsp_in_nested_expand_roundtrip() {
16903        // Issue #411 real-world case: NBSP paragraph inside nestedExpand
16904        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"nestedExpand\",\"attrs\":{\"title\":\"Section\"},\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}]}]}";
16905        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16906        let md = adf_to_markdown(&doc).unwrap();
16907        let rt = markdown_to_adf(&md).unwrap();
16908        let ne = &rt.content[0];
16909        assert_eq!(ne.node_type, "nestedExpand");
16910        let inner = ne.content.as_ref().unwrap();
16911        assert_eq!(inner.len(), 1, "should have 1 inner block");
16912        assert_eq!(inner[0].node_type, "paragraph");
16913        let content = inner[0].content.as_ref().unwrap();
16914        assert!(!content.is_empty(), "paragraph should not be empty");
16915        let text = content[0].text.as_deref().unwrap_or("");
16916        assert_eq!(text, "\u{00a0}", "NBSP should survive in nestedExpand");
16917    }
16918
16919    #[test]
16920    fn nbsp_followed_by_content() {
16921        // NBSP paragraph followed by regular content should not interfere
16922        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\"}]}]}";
16923        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16924        let md = adf_to_markdown(&doc).unwrap();
16925        let rt = markdown_to_adf(&md).unwrap();
16926        assert!(rt.content.len() >= 2, "should have at least 2 blocks");
16927        // The second block should be a paragraph with "after"
16928        let after_para = rt.content.iter().find(|n| {
16929            n.node_type == "paragraph"
16930                && n.content
16931                    .as_ref()
16932                    .and_then(|c| c.first())
16933                    .and_then(|n| n.text.as_deref())
16934                    .is_some_and(|t| t.contains("after"))
16935        });
16936        assert!(after_para.is_some(), "should have paragraph with 'after'");
16937    }
16938
16939    #[test]
16940    fn nbsp_paragraph_with_marks_survives() {
16941        // NBSP with bold marks renders as `** **` which contains non-whitespace
16942        // chars and thus doesn't need the directive form — it round-trips naturally
16943        let adf_json = "{\"version\":1,\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\",\"marks\":[{\"type\":\"strong\"}]}]}]}";
16944        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16945        let md = adf_to_markdown(&doc).unwrap();
16946        assert!(md.contains("**"), "should have bold markers: {md}");
16947        let rt = markdown_to_adf(&md).unwrap();
16948        let content = rt.content[0].content.as_ref().unwrap();
16949        assert!(!content.is_empty(), "should preserve content");
16950    }
16951
16952    #[test]
16953    fn regular_paragraph_unchanged() {
16954        // Regression guard: normal paragraphs should NOT use directive form
16955        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello"}]}]}"#;
16956        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16957        let md = adf_to_markdown(&doc).unwrap();
16958        assert!(
16959            !md.contains("::paragraph"),
16960            "regular paragraphs should not use directive form: {md}"
16961        );
16962        assert!(md.contains("hello"));
16963    }
16964
16965    #[test]
16966    fn paragraph_directive_with_content_parsed() {
16967        // ::paragraph[content] should parse to a paragraph with inline nodes
16968        let md = "::paragraph[\u{00a0}]\n";
16969        let doc = markdown_to_adf(md).unwrap();
16970        assert_eq!(doc.content.len(), 1);
16971        assert_eq!(doc.content[0].node_type, "paragraph");
16972        let content = doc.content[0].content.as_ref().unwrap();
16973        assert!(!content.is_empty(), "should have inline content");
16974        assert_eq!(content[0].text.as_deref().unwrap(), "\u{00a0}");
16975    }
16976
16977    #[test]
16978    fn nbsp_paragraph_in_list_item_with_nested_list() {
16979        // Issue #448: NBSP paragraph content lost inside listItem with nested bulletList
16980        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"}]}]}]}]}]}]}"#;
16981        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
16982        let md = adf_to_markdown(&doc).unwrap();
16983        let rt = markdown_to_adf(&md).unwrap();
16984        let list = &rt.content[0];
16985        assert_eq!(list.node_type, "bulletList");
16986        let item = &list.content.as_ref().unwrap()[0];
16987        let item_content = item.content.as_ref().unwrap();
16988        assert_eq!(
16989            item_content.len(),
16990            2,
16991            "listItem should have paragraph + nested list, got: {item_content:?}"
16992        );
16993        let para = &item_content[0];
16994        assert_eq!(para.node_type, "paragraph");
16995        let para_content = para
16996            .content
16997            .as_ref()
16998            .expect("paragraph should have content");
16999        assert!(
17000            !para_content.is_empty(),
17001            "NBSP paragraph content should not be empty"
17002        );
17003        assert_eq!(
17004            para_content[0].text.as_deref().unwrap(),
17005            "\u{00a0}",
17006            "NBSP should survive round-trip inside listItem"
17007        );
17008    }
17009
17010    #[test]
17011    fn nbsp_paragraph_in_list_item_with_local_ids() {
17012        // Issue #448: NBSP paragraph with localIds inside listItem with nested list
17013        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"}]}]}]}]}]}]}"#;
17014        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17015        let md = adf_to_markdown(&doc).unwrap();
17016        let rt = markdown_to_adf(&md).unwrap();
17017        let list = &rt.content[0];
17018        let item = &list.content.as_ref().unwrap()[0];
17019        // Check listItem localId
17020        assert_eq!(
17021            item.attrs.as_ref().unwrap()["localId"],
17022            "li-001",
17023            "listItem localId should survive"
17024        );
17025        let item_content = item.content.as_ref().unwrap();
17026        assert_eq!(item_content.len(), 2);
17027        // Check paragraph localId and NBSP content
17028        let para = &item_content[0];
17029        assert_eq!(
17030            para.attrs.as_ref().unwrap()["localId"],
17031            "p-001",
17032            "paragraph localId should survive"
17033        );
17034        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17035        assert_eq!(text, "\u{00a0}", "NBSP should survive with localIds");
17036    }
17037
17038    #[test]
17039    fn nbsp_paragraph_in_list_item_without_nested_list() {
17040        // NBSP paragraph in a simple listItem (no nested list)
17041        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"}]}]}]}]}"#;
17042        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17043        let md = adf_to_markdown(&doc).unwrap();
17044        let rt = markdown_to_adf(&md).unwrap();
17045        let list = &rt.content[0];
17046        let item = &list.content.as_ref().unwrap()[0];
17047        let para = &item.content.as_ref().unwrap()[0];
17048        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17049        assert_eq!(text, "\u{00a0}", "NBSP should survive in simple list item");
17050    }
17051
17052    #[test]
17053    fn nbsp_paragraph_in_ordered_list_item_with_nested_list() {
17054        // NBSP paragraph in ordered listItem with nested bulletList
17055        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"}]}]}]}]}]}]}"#;
17056        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17057        let md = adf_to_markdown(&doc).unwrap();
17058        let rt = markdown_to_adf(&md).unwrap();
17059        let list = &rt.content[0];
17060        let item = &list.content.as_ref().unwrap()[0];
17061        let item_content = item.content.as_ref().unwrap();
17062        assert_eq!(item_content.len(), 2);
17063        let para = &item_content[0];
17064        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
17065        assert_eq!(text, "\u{00a0}", "NBSP should survive in ordered list item");
17066    }
17067
17068    #[test]
17069    fn list_item_leading_space_preserved() {
17070        // Leading space in list item text must not be stripped
17071        let md = "- hello world\n- - text";
17072        let doc = markdown_to_adf(md).unwrap();
17073        let list = &doc.content[0];
17074        assert_eq!(list.node_type, "bulletList");
17075        let items = list.content.as_ref().unwrap();
17076        // First item: "hello world" (no leading space, unchanged)
17077        let first_para = &items[0].content.as_ref().unwrap()[0];
17078        let first_text = &first_para.content.as_ref().unwrap()[0];
17079        assert_eq!(first_text.text.as_deref(), Some("hello world"));
17080    }
17081
17082    #[test]
17083    fn list_item_leading_space_not_stripped() {
17084        // When the markdown list item content has a leading space (e.g. " :emoji:"),
17085        // that space must reach parse_inline as-is.
17086        let md = "-  leading space text";
17087        let doc = markdown_to_adf(md).unwrap();
17088        let list = &doc.content[0];
17089        let items = list.content.as_ref().unwrap();
17090        let para = &items[0].content.as_ref().unwrap()[0];
17091        let text_node = &para.content.as_ref().unwrap()[0];
17092        // After "- " (2 chars), trim_end keeps the leading space: " leading space text"
17093        assert_eq!(
17094            text_node.text.as_deref(),
17095            Some(" leading space text"),
17096            "leading space should be preserved"
17097        );
17098    }
17099
17100    // ── Nested container directive tests ───────────────────────────
17101
17102    // ── hardBreak in table cell tests ────────────────────────────
17103
17104    #[test]
17105    fn hardbreak_in_cell_uses_directive_table() {
17106        // A table cell with a hardBreak should NOT use pipe syntax
17107        // because the newline would break the row
17108        let adf = AdfDocument {
17109            version: 1,
17110            doc_type: "doc".to_string(),
17111            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17112                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17113                    AdfNode::text("before"),
17114                    AdfNode::hard_break(),
17115                    AdfNode::text("after"),
17116                ])]),
17117            ])])],
17118        };
17119        let md = adf_to_markdown(&adf).unwrap();
17120        // Should render as directive table, not pipe table
17121        assert!(
17122            md.contains(":::td") || md.contains("::::table"),
17123            "Table with hardBreak should use directive form, got:\n{md}"
17124        );
17125        assert!(
17126            !md.contains("| before"),
17127            "Should NOT use pipe syntax with hardBreak"
17128        );
17129    }
17130
17131    #[test]
17132    fn hardbreak_in_cell_roundtrips() {
17133        // Verify the directive table form preserves the hardBreak on round-trip
17134        let adf = AdfDocument {
17135            version: 1,
17136            doc_type: "doc".to_string(),
17137            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17138                AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17139                    AdfNode::text("line one"),
17140                    AdfNode::hard_break(),
17141                    AdfNode::text("line two"),
17142                ])]),
17143            ])])],
17144        };
17145        let md = adf_to_markdown(&adf).unwrap();
17146        let roundtripped = markdown_to_adf(&md).unwrap();
17147
17148        // Should still have one table with one row with one cell
17149        assert_eq!(roundtripped.content.len(), 1);
17150        assert_eq!(roundtripped.content[0].node_type, "table");
17151        let rows = roundtripped.content[0].content.as_ref().unwrap();
17152        assert_eq!(
17153            rows.len(),
17154            1,
17155            "Should have exactly 1 row, got {}",
17156            rows.len()
17157        );
17158    }
17159
17160    #[test]
17161    fn hardbreak_in_paragraph_roundtrips() {
17162        // Issue #373: hardBreak absorbed into preceding text node
17163        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17164          {"type":"text","text":"line one"},
17165          {"type":"hardBreak"},
17166          {"type":"text","text":"line two"}
17167        ]}]}"#;
17168        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17169        let md = adf_to_markdown(&doc).unwrap();
17170        let round_tripped = markdown_to_adf(&md).unwrap();
17171        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17172        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17173        assert_eq!(
17174            types,
17175            vec!["text", "hardBreak", "text"],
17176            "hardBreak should be preserved, got: {types:?}"
17177        );
17178        assert_eq!(inlines[0].text.as_deref(), Some("line one"));
17179        assert_eq!(inlines[2].text.as_deref(), Some("line two"));
17180    }
17181
17182    #[test]
17183    fn consecutive_hardbreaks_in_paragraph_roundtrip() {
17184        // Issue #410: consecutive hardBreak nodes collapsed on round-trip
17185        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17186          {"type":"text","text":"before"},
17187          {"type":"hardBreak"},
17188          {"type":"hardBreak"},
17189          {"type":"text","text":"after"}
17190        ]}]}"#;
17191        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17192        let md = adf_to_markdown(&doc).unwrap();
17193        let round_tripped = markdown_to_adf(&md).unwrap();
17194        assert_eq!(
17195            round_tripped.content.len(),
17196            1,
17197            "Should remain a single paragraph, got {} blocks",
17198            round_tripped.content.len()
17199        );
17200        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17201        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17202        assert_eq!(
17203            types,
17204            vec!["text", "hardBreak", "hardBreak", "text"],
17205            "Both hardBreaks should be preserved, got: {types:?}"
17206        );
17207        assert_eq!(inlines[0].text.as_deref(), Some("before"));
17208        assert_eq!(inlines[3].text.as_deref(), Some("after"));
17209    }
17210
17211    #[test]
17212    fn hardbreak_only_paragraph_roundtrips() {
17213        // Issue #410: paragraph whose only content is a hardBreak is dropped
17214        let adf_json = r#"{"version":1,"type":"doc","content":[
17215          {"type":"paragraph","content":[{"type":"hardBreak"}]}
17216        ]}"#;
17217        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17218        let md = adf_to_markdown(&doc).unwrap();
17219        let round_tripped = markdown_to_adf(&md).unwrap();
17220        assert_eq!(
17221            round_tripped.content.len(),
17222            1,
17223            "Paragraph should not be dropped, got {} blocks",
17224            round_tripped.content.len()
17225        );
17226        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17227        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17228        assert_eq!(
17229            types,
17230            vec!["hardBreak"],
17231            "hardBreak-only paragraph should preserve its content, got: {types:?}"
17232        );
17233    }
17234
17235    #[test]
17236    fn issue_410_full_reproducer_roundtrips() {
17237        // Full reproducer from issue #410: consecutive hardBreaks + hardBreak-only paragraph
17238        let adf_json = r#"{"version":1,"type":"doc","content":[
17239          {"type":"paragraph","content":[
17240            {"type":"text","text":"before"},
17241            {"type":"hardBreak"},
17242            {"type":"hardBreak"},
17243            {"type":"text","text":"after"}
17244          ]},
17245          {"type":"paragraph","content":[
17246            {"type":"hardBreak"}
17247          ]}
17248        ]}"#;
17249        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17250        let md = adf_to_markdown(&doc).unwrap();
17251        let round_tripped = markdown_to_adf(&md).unwrap();
17252        assert_eq!(
17253            round_tripped.content.len(),
17254            2,
17255            "Should have exactly 2 paragraphs, got {}",
17256            round_tripped.content.len()
17257        );
17258        // First paragraph: text, hardBreak, hardBreak, text
17259        let p1 = round_tripped.content[0].content.as_ref().unwrap();
17260        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
17261        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak", "text"]);
17262        // Second paragraph: hardBreak only
17263        let p2 = round_tripped.content[1].content.as_ref().unwrap();
17264        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
17265        assert_eq!(types2, vec!["hardBreak"]);
17266    }
17267
17268    #[test]
17269    fn trailing_space_hardbreak_still_parsed() {
17270        // Backward compatibility: trailing-space hardBreak (old JFM format) still parses
17271        let md = "line one  \nline two\n";
17272        let doc = markdown_to_adf(md).unwrap();
17273        let inlines = doc.content[0].content.as_ref().unwrap();
17274        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17275        assert_eq!(
17276            types,
17277            vec!["text", "hardBreak", "text"],
17278            "Trailing-space hardBreak should still parse, got: {types:?}"
17279        );
17280    }
17281
17282    #[test]
17283    fn trailing_hardbreak_at_end_of_paragraph_roundtrips() {
17284        // A paragraph ending with a hardBreak (no text after it)
17285        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
17286          {"type":"text","text":"text"},
17287          {"type":"hardBreak"}
17288        ]}]}"#;
17289        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17290        let md = adf_to_markdown(&doc).unwrap();
17291        let round_tripped = markdown_to_adf(&md).unwrap();
17292        let inlines = round_tripped.content[0].content.as_ref().unwrap();
17293        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
17294        assert_eq!(
17295            types,
17296            vec!["text", "hardBreak"],
17297            "Trailing hardBreak should be preserved, got: {types:?}"
17298        );
17299    }
17300
17301    #[test]
17302    #[test]
17303    fn table_with_header_row_uses_pipe_syntax() {
17304        // A table with tableHeader in the first row should use pipe syntax
17305        let adf = AdfDocument {
17306            version: 1,
17307            doc_type: "doc".to_string(),
17308            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17309                AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("header cell")])]),
17310            ])])],
17311        };
17312        let md = adf_to_markdown(&adf).unwrap();
17313        assert!(
17314            md.contains("| header cell |"),
17315            "Table with header row should use pipe syntax, got:\n{md}"
17316        );
17317    }
17318
17319    #[test]
17320    fn table_without_header_row_uses_directive_syntax() {
17321        // Issue #392: tableCell-only first row must use directive syntax
17322        // to avoid converting tableCell → tableHeader on round-trip
17323        let adf = AdfDocument {
17324            version: 1,
17325            doc_type: "doc".to_string(),
17326            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17327                AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("simple cell")])]),
17328            ])])],
17329        };
17330        let md = adf_to_markdown(&adf).unwrap();
17331        assert!(
17332            md.contains("::::table"),
17333            "Table without header row should use directive syntax, got:\n{md}"
17334        );
17335    }
17336
17337    #[test]
17338    fn tablecell_first_row_preserved_on_roundtrip() {
17339        // Issue #392: tableCell in first row round-trips as tableHeader
17340        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{},"content":[
17341          {"type":"tableRow","content":[
17342            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row1 cell"}]}]}
17343          ]},
17344          {"type":"tableRow","content":[
17345            {"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"row2 cell"}]}]}
17346          ]}
17347        ]}]}"#;
17348        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17349        let md = adf_to_markdown(&doc).unwrap();
17350        let round_tripped = markdown_to_adf(&md).unwrap();
17351        let rows = round_tripped.content[0].content.as_ref().unwrap();
17352        let row0_cell = &rows[0].content.as_ref().unwrap()[0];
17353        assert_eq!(
17354            row0_cell.node_type, "tableCell",
17355            "first row cell should remain tableCell, got: {}",
17356            row0_cell.node_type
17357        );
17358        let row1_cell = &rows[1].content.as_ref().unwrap()[0];
17359        assert_eq!(row1_cell.node_type, "tableCell");
17360    }
17361
17362    #[test]
17363    fn mixed_header_and_cell_first_row_uses_pipe() {
17364        // A first row with at least one tableHeader qualifies for pipe syntax
17365        let adf = AdfDocument {
17366            version: 1,
17367            doc_type: "doc".to_string(),
17368            content: vec![AdfNode::table(vec![
17369                AdfNode::table_row(vec![
17370                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17371                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17372                ]),
17373                AdfNode::table_row(vec![
17374                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C1")])]),
17375                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("C2")])]),
17376                ]),
17377            ])],
17378        };
17379        let md = adf_to_markdown(&adf).unwrap();
17380        assert!(
17381            md.contains("| H1 |"),
17382            "Table with header first row should use pipe syntax, got:\n{md}"
17383        );
17384        assert!(!md.contains("::::table"), "should not use directive syntax");
17385    }
17386
17387    // ── Issue #579: pipes in pipe-table cells ─────────────────────
17388
17389    #[test]
17390    fn render_pipe_table_escapes_pipe_in_code_span_cell() {
17391        // A code-marked text node with a literal `|` in a pipe-table cell
17392        // must emit `\|` so the column separator is unambiguous.
17393        let adf = AdfDocument {
17394            version: 1,
17395            doc_type: "doc".to_string(),
17396            content: vec![AdfNode::table(vec![
17397                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17398                    AdfNode::text("Header"),
17399                ])])]),
17400                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17401                    AdfNode::text_with_marks("a|b", vec![AdfMark::code()]),
17402                ])])]),
17403            ])],
17404        };
17405        let md = adf_to_markdown(&adf).unwrap();
17406        assert!(
17407            md.contains(r"`a\|b`"),
17408            "Pipe inside code span must be escaped, got:\n{md}"
17409        );
17410    }
17411
17412    #[test]
17413    fn render_pipe_table_escapes_pipe_in_plain_text_cell() {
17414        let adf = AdfDocument {
17415            version: 1,
17416            doc_type: "doc".to_string(),
17417            content: vec![AdfNode::table(vec![
17418                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
17419                    AdfNode::text("Header"),
17420                ])])]),
17421                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
17422                    AdfNode::text("x|y"),
17423                ])])]),
17424            ])],
17425        };
17426        let md = adf_to_markdown(&adf).unwrap();
17427        assert!(
17428            md.contains(r"x\|y"),
17429            "Pipe inside plain-text cell must be escaped, got:\n{md}"
17430        );
17431    }
17432
17433    #[test]
17434    fn code_span_with_pipe_in_table_cell_roundtrips() {
17435        // Issue #579 reproducer: code span containing `|` in a pipe-table cell.
17436        let adf_json = r#"{
17437            "version": 1,
17438            "type": "doc",
17439            "content": [{
17440                "type": "table",
17441                "attrs": {"isNumberColumnEnabled": false, "layout": "default", "localId": "abc-789"},
17442                "content": [
17443                    {"type": "tableRow", "content": [
17444                        {"type": "tableHeader", "attrs": {}, "content": [
17445                            {"type": "paragraph", "content": [{"type": "text", "text": "Before"}]}
17446                        ]},
17447                        {"type": "tableHeader", "attrs": {}, "content": [
17448                            {"type": "paragraph", "content": [{"type": "text", "text": "After"}]}
17449                        ]}
17450                    ]},
17451                    {"type": "tableRow", "content": [
17452                        {"type": "tableCell", "attrs": {}, "content": [
17453                            {"type": "paragraph", "content": [
17454                                {"type": "text", "text": "parse(json).extract[T]", "marks": [{"type": "code"}]}
17455                            ]}
17456                        ]},
17457                        {"type": "tableCell", "attrs": {}, "content": [
17458                            {"type": "paragraph", "content": [
17459                                {"type": "text", "text": "parser.decode[T|json]", "marks": [{"type": "code"}]}
17460                            ]}
17461                        ]}
17462                    ]}
17463                ]
17464            }]
17465        }"#;
17466        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17467        let md = adf_to_markdown(&doc).unwrap();
17468        let round_tripped = markdown_to_adf(&md).unwrap();
17469
17470        let rows = round_tripped.content[0].content.as_ref().unwrap();
17471        assert_eq!(
17472            rows.len(),
17473            2,
17474            "Table should have 2 rows, got: {}",
17475            rows.len()
17476        );
17477
17478        let body_row = rows[1].content.as_ref().unwrap();
17479        assert_eq!(
17480            body_row.len(),
17481            2,
17482            "Body row should have 2 cells (not split by the pipe), got: {}",
17483            body_row.len()
17484        );
17485
17486        let second_cell = &body_row[1];
17487        let para = second_cell.content.as_ref().unwrap().first().unwrap();
17488        let inlines = para.content.as_ref().unwrap();
17489        assert_eq!(inlines.len(), 1, "Cell should have a single text node");
17490        assert_eq!(
17491            inlines[0].text.as_deref(),
17492            Some("parser.decode[T|json]"),
17493            "Code-span text must be preserved with literal pipe"
17494        );
17495        let marks = inlines[0]
17496            .marks
17497            .as_ref()
17498            .expect("code mark must be preserved");
17499        assert!(
17500            marks.iter().any(|m| m.mark_type == "code"),
17501            "text node should carry the code mark"
17502        );
17503    }
17504
17505    #[test]
17506    fn plain_text_pipe_in_table_cell_roundtrips() {
17507        // Plain text with `|` in a pipe-table cell should also survive.
17508        let adf = AdfDocument {
17509            version: 1,
17510            doc_type: "doc".to_string(),
17511            content: vec![AdfNode::table(vec![
17512                AdfNode::table_row(vec![
17513                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H1")])]),
17514                    AdfNode::table_header(vec![AdfNode::paragraph(vec![AdfNode::text("H2")])]),
17515                ]),
17516                AdfNode::table_row(vec![
17517                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("a|b")])]),
17518                    AdfNode::table_cell(vec![AdfNode::paragraph(vec![AdfNode::text("c")])]),
17519                ]),
17520            ])],
17521        };
17522        let md = adf_to_markdown(&adf).unwrap();
17523        let round_tripped = markdown_to_adf(&md).unwrap();
17524        let rows = round_tripped.content[0].content.as_ref().unwrap();
17525        let body_row = rows[1].content.as_ref().unwrap();
17526        assert_eq!(
17527            body_row.len(),
17528            2,
17529            "Body row should keep 2 cells, got: {}",
17530            body_row.len()
17531        );
17532        let first_cell_text = body_row[0].content.as_ref().unwrap()[0]
17533            .content
17534            .as_ref()
17535            .unwrap()[0]
17536            .text
17537            .as_deref();
17538        assert_eq!(first_cell_text, Some("a|b"));
17539    }
17540
17541    #[test]
17542    fn cell_contains_hard_break_true() {
17543        let para = AdfNode::paragraph(vec![
17544            AdfNode::text("a"),
17545            AdfNode::hard_break(),
17546            AdfNode::text("b"),
17547        ]);
17548        assert!(cell_contains_hard_break(&para));
17549    }
17550
17551    #[test]
17552    fn cell_contains_hard_break_false() {
17553        let para = AdfNode::paragraph(vec![AdfNode::text("no break here")]);
17554        assert!(!cell_contains_hard_break(&para));
17555    }
17556
17557    #[test]
17558    fn cell_contains_hard_break_empty() {
17559        let para = AdfNode::paragraph(vec![]);
17560        assert!(!cell_contains_hard_break(&para));
17561    }
17562
17563    // ── Multi-paragraph container tests ──────────────────────────
17564
17565    #[test]
17566    fn multi_paragraph_panel_roundtrips() {
17567        let adf = AdfDocument {
17568            version: 1,
17569            doc_type: "doc".to_string(),
17570            content: vec![AdfNode {
17571                node_type: "panel".to_string(),
17572                attrs: Some(serde_json::json!({"panelType": "info"})),
17573                content: Some(vec![
17574                    AdfNode::paragraph(vec![AdfNode::text("First paragraph.")]),
17575                    AdfNode::paragraph(vec![AdfNode::text("Second paragraph.")]),
17576                ]),
17577                text: None,
17578                marks: None,
17579                local_id: None,
17580                parameters: None,
17581            }],
17582        };
17583
17584        let md = adf_to_markdown(&adf).unwrap();
17585        // Should have blank line between paragraphs inside the panel
17586        assert!(
17587            md.contains("First paragraph.\n\nSecond paragraph."),
17588            "Panel should have blank line between paragraphs, got:\n{md}"
17589        );
17590
17591        // Round-trip should preserve two separate paragraphs
17592        let roundtripped = markdown_to_adf(&md).unwrap();
17593        assert_eq!(roundtripped.content.len(), 1);
17594        assert_eq!(roundtripped.content[0].node_type, "panel");
17595        let panel_content = roundtripped.content[0].content.as_ref().unwrap();
17596        assert_eq!(
17597            panel_content.len(),
17598            2,
17599            "Panel should have 2 paragraphs after round-trip, got {}",
17600            panel_content.len()
17601        );
17602    }
17603
17604    #[test]
17605    fn multi_paragraph_expand_roundtrips() {
17606        let adf = AdfDocument {
17607            version: 1,
17608            doc_type: "doc".to_string(),
17609            content: vec![AdfNode {
17610                node_type: "expand".to_string(),
17611                attrs: Some(serde_json::json!({"title": "Details"})),
17612                content: Some(vec![
17613                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17614                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17615                ]),
17616                text: None,
17617                marks: None,
17618                local_id: None,
17619                parameters: None,
17620            }],
17621        };
17622
17623        let md = adf_to_markdown(&adf).unwrap();
17624        let roundtripped = markdown_to_adf(&md).unwrap();
17625        let expand_content = roundtripped.content[0].content.as_ref().unwrap();
17626        assert_eq!(
17627            expand_content.len(),
17628            2,
17629            "Expand should have 2 paragraphs after round-trip, got {}",
17630            expand_content.len()
17631        );
17632    }
17633
17634    #[test]
17635    fn consecutive_nested_expands_in_table_cell_roundtrip() {
17636        let cell_content = vec![
17637            AdfNode {
17638                node_type: "nestedExpand".to_string(),
17639                attrs: Some(serde_json::json!({"title": "First"})),
17640                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 1")])]),
17641                text: None,
17642                marks: None,
17643                local_id: None,
17644                parameters: None,
17645            },
17646            AdfNode {
17647                node_type: "nestedExpand".to_string(),
17648                attrs: Some(serde_json::json!({"title": "Second"})),
17649                content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("item 2")])]),
17650                text: None,
17651                marks: None,
17652                local_id: None,
17653                parameters: None,
17654            },
17655        ];
17656        let adf = AdfDocument {
17657            version: 1,
17658            doc_type: "doc".to_string(),
17659            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17660                AdfNode::table_cell(cell_content),
17661            ])])],
17662        };
17663
17664        let md = adf_to_markdown(&adf).unwrap();
17665        assert!(
17666            md.contains(":::\n\n:::nested-expand"),
17667            "Should have blank line between consecutive nested-expands in cell, got:\n{md}"
17668        );
17669
17670        let rt = markdown_to_adf(&md).unwrap();
17671        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17672            .content
17673            .as_ref()
17674            .unwrap()[0];
17675        let cell_nodes = cell.content.as_ref().unwrap();
17676        let expand_count = cell_nodes
17677            .iter()
17678            .filter(|n| n.node_type == "nestedExpand")
17679            .count();
17680        assert_eq!(
17681            expand_count, 2,
17682            "Both nested-expands should survive round-trip, got {expand_count}"
17683        );
17684    }
17685
17686    #[test]
17687    fn multi_paragraph_in_table_cell_roundtrip() {
17688        // Two paragraphs inside a directive table cell should survive round-trip
17689        let adf = AdfDocument {
17690            version: 1,
17691            doc_type: "doc".to_string(),
17692            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17693                AdfNode::table_cell(vec![
17694                    AdfNode::paragraph(vec![AdfNode::text("Para one.")]),
17695                    AdfNode::paragraph(vec![AdfNode::text("Para two.")]),
17696                ]),
17697            ])])],
17698        };
17699
17700        let md = adf_to_markdown(&adf).unwrap();
17701        assert!(
17702            md.contains("Para one.\n\nPara two."),
17703            "Should have blank line between paragraphs in cell, got:\n{md}"
17704        );
17705
17706        let rt = markdown_to_adf(&md).unwrap();
17707        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17708            .content
17709            .as_ref()
17710            .unwrap()[0];
17711        let para_count = cell
17712            .content
17713            .as_ref()
17714            .unwrap()
17715            .iter()
17716            .filter(|n| n.node_type == "paragraph")
17717            .count();
17718        assert_eq!(para_count, 2, "Both paragraphs should survive round-trip");
17719    }
17720
17721    #[test]
17722    fn panel_inside_table_cell_roundtrip() {
17723        // A panel inside a directive table cell
17724        let adf = AdfDocument {
17725            version: 1,
17726            doc_type: "doc".to_string(),
17727            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17728                AdfNode::table_cell(vec![
17729                    AdfNode::paragraph(vec![AdfNode::text("Before panel.")]),
17730                    AdfNode {
17731                        node_type: "panel".to_string(),
17732                        attrs: Some(serde_json::json!({"panelType": "info"})),
17733                        content: Some(vec![AdfNode::paragraph(vec![AdfNode::text(
17734                            "Panel content",
17735                        )])]),
17736                        text: None,
17737                        marks: None,
17738                        local_id: None,
17739                        parameters: None,
17740                    },
17741                ]),
17742            ])])],
17743        };
17744
17745        let md = adf_to_markdown(&adf).unwrap();
17746        assert!(
17747            md.contains(":::panel"),
17748            "Should contain panel directive, got:\n{md}"
17749        );
17750
17751        let rt = markdown_to_adf(&md).unwrap();
17752        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17753            .content
17754            .as_ref()
17755            .unwrap()[0];
17756        let has_panel = cell
17757            .content
17758            .as_ref()
17759            .unwrap()
17760            .iter()
17761            .any(|n| n.node_type == "panel");
17762        assert!(has_panel, "Panel should survive round-trip in table cell");
17763    }
17764
17765    #[test]
17766    fn three_consecutive_expands_in_table_cell() {
17767        let make_expand = |title: &str| AdfNode {
17768            node_type: "nestedExpand".to_string(),
17769            attrs: Some(serde_json::json!({"title": title})),
17770            content: Some(vec![AdfNode::paragraph(vec![AdfNode::text("content")])]),
17771            text: None,
17772            marks: None,
17773            local_id: None,
17774            parameters: None,
17775        };
17776        let adf = AdfDocument {
17777            version: 1,
17778            doc_type: "doc".to_string(),
17779            content: vec![AdfNode::table(vec![AdfNode::table_row(vec![
17780                AdfNode::table_cell(vec![
17781                    make_expand("First"),
17782                    make_expand("Second"),
17783                    make_expand("Third"),
17784                ]),
17785            ])])],
17786        };
17787
17788        let md = adf_to_markdown(&adf).unwrap();
17789        let rt = markdown_to_adf(&md).unwrap();
17790        let cell = &rt.content[0].content.as_ref().unwrap()[0]
17791            .content
17792            .as_ref()
17793            .unwrap()[0];
17794        let expand_count = cell
17795            .content
17796            .as_ref()
17797            .unwrap()
17798            .iter()
17799            .filter(|n| n.node_type == "nestedExpand")
17800            .count();
17801        assert_eq!(expand_count, 3, "All 3 expands should survive round-trip");
17802    }
17803
17804    // ── Nested container directive tests ───────────────────────────
17805
17806    #[test]
17807    fn nested_expand_inside_panel() {
17808        // Issue #714: the converter still produces panel→expand at the AST
17809        // level (this is what users may type), but the document fails ADF
17810        // schema validation, surfacing an actionable error before the API
17811        // call rather than an opaque Confluence HTTP 500.
17812        let md = ":::panel{type=info}\n:::expand{title=\"Details\"}\nHidden content\n:::\nMore panel content\n:::";
17813        let adf = markdown_to_adf(md).unwrap();
17814
17815        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17816        assert!(err.violations.iter().any(|v| matches!(
17817            v,
17818            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17819                parent_type, child_type, ..
17820            } if parent_type == "panel" && child_type == "expand"
17821        )));
17822    }
17823
17824    #[test]
17825    fn nested_expand_inside_table_cell() {
17826        // Issue #714: tableCell → expand is a Confluence content-model
17827        // violation. Table cells require `nestedExpand` instead. Validation
17828        // catches this before the API call.
17829        let md = "::::table\n:::tr\n:::td\n:::expand{title=\"Details\"}\nExpand content\n:::\n:::\n:::\n::::";
17830        let adf = markdown_to_adf(md).unwrap();
17831
17832        let err = crate::atlassian::adf_validated::validate(&adf).unwrap_err();
17833        assert!(err.violations.iter().any(|v| matches!(
17834            v,
17835            crate::atlassian::adf_schema::AdfSchemaViolation::DisallowedChild {
17836                parent_type, child_type, ..
17837            } if parent_type == "tableCell" && child_type == "expand"
17838        )));
17839    }
17840
17841    #[test]
17842    fn nested_expand_inside_layout_column() {
17843        // Issue #714 sanity check: `expand` inside a `layoutColumn` is
17844        // legitimate per the ADF schema and must NOT trigger validation.
17845        // Note: layoutSection requires 2..=3 columns per #733's quantifier
17846        // checks, so the markdown declares two columns.
17847        let md = ":::layout\n:::column{width=50}\n:::expand{title=\"Col Expand\"}\nExpanded\n:::\n:::\n:::column{width=50}\nFiller paragraph.\n:::\n:::";
17848        let adf = markdown_to_adf(md).unwrap();
17849
17850        assert_eq!(adf.content.len(), 1);
17851        assert_eq!(adf.content[0].node_type, "layoutSection");
17852
17853        let columns = adf.content[0].content.as_ref().unwrap();
17854        assert_eq!(columns.len(), 2);
17855        let col_content = columns[0].content.as_ref().unwrap();
17856        assert!(
17857            col_content.iter().any(|n| n.node_type == "expand"),
17858            "Column should contain an expand node, got: {:?}",
17859            col_content.iter().map(|n| &n.node_type).collect::<Vec<_>>()
17860        );
17861
17862        // Validation must not flag this legitimate nesting.
17863        crate::atlassian::adf_validated::validate(&adf).unwrap();
17864    }
17865
17866    #[test]
17867    fn expand_localid_in_directive_attrs() {
17868        // Issue #412: localId should be in directive attrs, not trailing text
17869        let adf_json = r#"{"version":1,"type":"doc","content":[
17870          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17871            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17872          ]}
17873        ]}"#;
17874        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17875        let md = adf_to_markdown(&doc).unwrap();
17876        assert!(
17877            md.contains("localId=exp-001"),
17878            "should contain localId: {md}"
17879        );
17880        assert!(
17881            md.contains(":::expand{"),
17882            "should have expand directive with attrs: {md}"
17883        );
17884        assert!(
17885            !md.contains(":::\n{localId="),
17886            "localId should NOT be trailing: {md}"
17887        );
17888    }
17889
17890    #[test]
17891    fn expand_localid_roundtrip() {
17892        let adf_json = r#"{"version":1,"type":"doc","content":[
17893          {"type":"expand","attrs":{"localId":"exp-001","title":"Details"},"content":[
17894            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17895          ]}
17896        ]}"#;
17897        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17898        let md = adf_to_markdown(&doc).unwrap();
17899        let rt = markdown_to_adf(&md).unwrap();
17900        let expand = &rt.content[0];
17901        assert_eq!(expand.node_type, "expand");
17902        assert_eq!(
17903            expand.local_id.as_deref(),
17904            Some("exp-001"),
17905            "expand localId should survive round-trip"
17906        );
17907        assert_eq!(
17908            expand.attrs.as_ref().unwrap()["title"],
17909            "Details",
17910            "expand title should survive round-trip"
17911        );
17912    }
17913
17914    #[test]
17915    fn nested_expand_localid_roundtrip() {
17916        let adf_json = r#"{"version":1,"type":"doc","content":[
17917          {"type":"nestedExpand","attrs":{"localId":"ne-001","title":"S"},"content":[
17918            {"type":"paragraph","content":[{"type":"text","text":"content"}]}
17919          ]}
17920        ]}"#;
17921        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17922        let md = adf_to_markdown(&doc).unwrap();
17923        assert!(
17924            md.contains(":::nested-expand{"),
17925            "should have directive: {md}"
17926        );
17927        assert!(md.contains("localId=ne-001"), "should have localId: {md}");
17928        let rt = markdown_to_adf(&md).unwrap();
17929        let ne = &rt.content[0];
17930        assert_eq!(ne.node_type, "nestedExpand");
17931        assert_eq!(ne.local_id.as_deref(), Some("ne-001"));
17932    }
17933
17934    #[test]
17935    fn nested_expand_localid_followed_by_content() {
17936        // Issue #412 reproducer: localId must not leak into following paragraph
17937        let adf_json = "{\
17938            \"version\":1,\"type\":\"doc\",\"content\":[\
17939              {\"type\":\"nestedExpand\",\"attrs\":{\"localId\":\"exp-001\",\"title\":\"S\"},\"content\":[\
17940                {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"\\u00a0\"}]}\
17941              ]},\
17942              {\"type\":\"paragraph\",\"content\":[{\"type\":\"text\",\"text\":\"after\"}]}\
17943            ]}";
17944        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17945        let md = adf_to_markdown(&doc).unwrap();
17946        let rt = markdown_to_adf(&md).unwrap();
17947        // nestedExpand should have localId
17948        let ne = &rt.content[0];
17949        assert_eq!(ne.node_type, "nestedExpand");
17950        assert_eq!(
17951            ne.local_id.as_deref(),
17952            Some("exp-001"),
17953            "nestedExpand should preserve localId"
17954        );
17955        // Following paragraph should contain "after", not "{localId=...}"
17956        let para = &rt.content[1];
17957        assert_eq!(para.node_type, "paragraph");
17958        let text = para.content.as_ref().unwrap()[0]
17959            .text
17960            .as_deref()
17961            .unwrap_or("");
17962        assert!(
17963            !text.contains("localId"),
17964            "following paragraph should not contain localId: {text}"
17965        );
17966        assert!(
17967            text.contains("after"),
17968            "following paragraph should contain 'after': {text}"
17969        );
17970    }
17971
17972    #[test]
17973    fn expand_localid_without_title() {
17974        let adf_json = r#"{"version":1,"type":"doc","content":[
17975          {"type":"expand","attrs":{"localId":"exp-002"},"content":[
17976            {"type":"paragraph","content":[{"type":"text","text":"no title"}]}
17977          ]}
17978        ]}"#;
17979        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17980        let md = adf_to_markdown(&doc).unwrap();
17981        assert!(
17982            md.contains(":::expand{localId=exp-002}"),
17983            "should have localId without title: {md}"
17984        );
17985        let rt = markdown_to_adf(&md).unwrap();
17986        assert_eq!(rt.content[0].local_id.as_deref(), Some("exp-002"));
17987    }
17988
17989    #[test]
17990    fn expand_localid_stripped() {
17991        let adf_json = r#"{"version":1,"type":"doc","content":[
17992          {"type":"expand","attrs":{"localId":"exp-001","title":"X"},"content":[
17993            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
17994          ]}
17995        ]}"#;
17996        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
17997        let opts = RenderOptions {
17998            strip_local_ids: true,
17999        };
18000        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18001        assert!(!md.contains("localId"), "localId should be stripped: {md}");
18002        assert!(
18003            md.contains(":::expand{title=\"X\"}"),
18004            "title should remain: {md}"
18005        );
18006    }
18007
18008    // ── Issue #444: top-level localId and parameters on expand ──
18009
18010    #[test]
18011    fn expand_top_level_localid_roundtrip() {
18012        // localId as a top-level field (not inside attrs) should survive round-trip
18013        let adf_json = r#"{"version":1,"type":"doc","content":[
18014          {"type":"expand","attrs":{"title":"My Section"},"localId":"abc-123","content":[
18015            {"type":"paragraph","content":[{"type":"text","text":"hello"}]}
18016          ]}
18017        ]}"#;
18018        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18019        assert_eq!(doc.content[0].local_id.as_deref(), Some("abc-123"));
18020        let md = adf_to_markdown(&doc).unwrap();
18021        assert!(
18022            md.contains("localId=abc-123"),
18023            "JFM should contain localId: {md}"
18024        );
18025        let rt = markdown_to_adf(&md).unwrap();
18026        let expand = &rt.content[0];
18027        assert_eq!(expand.node_type, "expand");
18028        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18029        assert_eq!(
18030            expand.attrs.as_ref().unwrap()["title"],
18031            "My Section",
18032            "title should survive round-trip"
18033        );
18034    }
18035
18036    #[test]
18037    fn expand_parameters_roundtrip() {
18038        // parameters (macroMetadata) should survive round-trip
18039        let adf_json = r#"{"version":1,"type":"doc","content":[
18040          {"type":"expand","attrs":{"title":"Props"},"parameters":{"macroMetadata":{"macroId":{"value":"m-001"},"schemaVersion":{"value":"1"}}},"content":[
18041            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18042          ]}
18043        ]}"#;
18044        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18045        assert!(doc.content[0].parameters.is_some());
18046        let md = adf_to_markdown(&doc).unwrap();
18047        assert!(md.contains("params="), "JFM should contain params: {md}");
18048        let rt = markdown_to_adf(&md).unwrap();
18049        let expand = &rt.content[0];
18050        let params = expand
18051            .parameters
18052            .as_ref()
18053            .expect("parameters should survive round-trip");
18054        assert_eq!(params["macroMetadata"]["macroId"]["value"], "m-001");
18055        assert_eq!(params["macroMetadata"]["schemaVersion"]["value"], "1");
18056    }
18057
18058    #[test]
18059    fn expand_localid_and_parameters_roundtrip() {
18060        // Issue #444: both localId and parameters on expand should survive round-trip
18061        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"}]}]}]}"#;
18062        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18063        let md = adf_to_markdown(&doc).unwrap();
18064        let rt = markdown_to_adf(&md).unwrap();
18065        let expand = &rt.content[0];
18066        assert_eq!(expand.node_type, "expand");
18067        assert_eq!(expand.local_id.as_deref(), Some("abc-123"));
18068        assert_eq!(expand.attrs.as_ref().unwrap()["title"], "My Section");
18069        let params = expand
18070            .parameters
18071            .as_ref()
18072            .expect("parameters should survive");
18073        assert_eq!(params["macroMetadata"]["macroId"]["value"], "macro-001");
18074        assert_eq!(params["macroMetadata"]["title"], "Page Properties");
18075    }
18076
18077    #[test]
18078    fn nested_expand_top_level_localid_and_parameters_roundtrip() {
18079        let adf_json = r#"{"version":1,"type":"doc","content":[
18080          {"type":"nestedExpand","attrs":{"title":"Nested"},"localId":"ne-100","parameters":{"macroMetadata":{"macroId":{"value":"nm-001"}}},"content":[
18081            {"type":"paragraph","content":[{"type":"text","text":"inner"}]}
18082          ]}
18083        ]}"#;
18084        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18085        let md = adf_to_markdown(&doc).unwrap();
18086        assert!(
18087            md.contains(":::nested-expand{"),
18088            "should use nested-expand: {md}"
18089        );
18090        assert!(md.contains("localId=ne-100"), "should have localId: {md}");
18091        assert!(md.contains("params="), "should have params: {md}");
18092        let rt = markdown_to_adf(&md).unwrap();
18093        let ne = &rt.content[0];
18094        assert_eq!(ne.node_type, "nestedExpand");
18095        assert_eq!(ne.local_id.as_deref(), Some("ne-100"));
18096        assert_eq!(
18097            ne.parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18098            "nm-001"
18099        );
18100    }
18101
18102    #[test]
18103    fn expand_top_level_localid_stripped() {
18104        // strip_local_ids should strip top-level localId too
18105        let adf_json = r#"{"version":1,"type":"doc","content":[
18106          {"type":"expand","attrs":{"title":"X"},"localId":"exp-strip","content":[
18107            {"type":"paragraph","content":[{"type":"text","text":"body"}]}
18108          ]}
18109        ]}"#;
18110        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18111        let opts = RenderOptions {
18112            strip_local_ids: true,
18113        };
18114        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18115        assert!(!md.contains("localId"), "localId should be stripped: {md}");
18116        assert!(
18117            md.contains(":::expand{title=\"X\"}"),
18118            "title should remain: {md}"
18119        );
18120    }
18121
18122    #[test]
18123    fn expand_parameters_without_localid() {
18124        // parameters without localId should work
18125        let adf_json = r#"{"version":1,"type":"doc","content":[
18126          {"type":"expand","attrs":{"title":"P"},"parameters":{"macroMetadata":{"macroId":{"value":"solo"}}},"content":[
18127            {"type":"paragraph","content":[{"type":"text","text":"data"}]}
18128          ]}
18129        ]}"#;
18130        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18131        let md = adf_to_markdown(&doc).unwrap();
18132        assert!(!md.contains("localId"), "no localId: {md}");
18133        assert!(md.contains("params="), "has params: {md}");
18134        let rt = markdown_to_adf(&md).unwrap();
18135        assert!(rt.content[0].local_id.is_none());
18136        assert_eq!(
18137            rt.content[0].parameters.as_ref().unwrap()["macroMetadata"]["macroId"]["value"],
18138            "solo"
18139        );
18140    }
18141
18142    #[test]
18143    fn expand_localid_without_parameters() {
18144        // top-level localId without parameters should work
18145        let adf_json = r#"{"version":1,"type":"doc","content":[
18146          {"type":"expand","attrs":{"title":"L"},"localId":"lid-only","content":[
18147            {"type":"paragraph","content":[{"type":"text","text":"txt"}]}
18148          ]}
18149        ]}"#;
18150        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18151        let md = adf_to_markdown(&doc).unwrap();
18152        assert!(md.contains("localId=lid-only"), "has localId: {md}");
18153        assert!(!md.contains("params="), "no params: {md}");
18154        let rt = markdown_to_adf(&md).unwrap();
18155        assert_eq!(rt.content[0].local_id.as_deref(), Some("lid-only"));
18156        assert!(rt.content[0].parameters.is_none());
18157    }
18158
18159    #[test]
18160    fn nested_panel_inside_panel() {
18161        let md = ":::panel{type=info}\n:::panel{type=warning}\nInner warning\n:::\n:::";
18162        let adf = markdown_to_adf(md).unwrap();
18163
18164        // Outer panel should exist
18165        assert_eq!(adf.content.len(), 1);
18166        assert_eq!(adf.content[0].node_type, "panel");
18167
18168        // Outer panel should contain an inner panel (not have it truncated)
18169        let panel_content = adf.content[0].content.as_ref().unwrap();
18170        assert!(
18171            panel_content.iter().any(|n| n.node_type == "panel"),
18172            "Outer panel should contain an inner panel, got: {:?}",
18173            panel_content
18174                .iter()
18175                .map(|n| &n.node_type)
18176                .collect::<Vec<_>>()
18177        );
18178    }
18179
18180    #[test]
18181    fn content_after_directive_table_is_preserved() {
18182        // Issue #361: content after a ::::table block was silently dropped
18183        let md = "\
18184## Before table
18185
18186::::table{layout=default}
18187:::tr
18188:::th{}
18189Cell
18190:::
18191:::
18192::::
18193
18194## After table
18195
18196Paragraph after.";
18197        let adf = markdown_to_adf(md).unwrap();
18198        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18199        assert_eq!(
18200            types,
18201            vec!["heading", "table", "heading", "paragraph"],
18202            "Content after table was dropped: got {types:?}"
18203        );
18204    }
18205
18206    #[test]
18207    fn paragraph_after_directive_table_is_preserved() {
18208        // Issue #361: minimal reproducer — paragraph after table
18209        let md = "\
18210::::table{layout=default}
18211:::tr
18212:::th{}
18213Header
18214:::
18215:::
18216::::
18217
18218Just a paragraph.";
18219        let adf = markdown_to_adf(md).unwrap();
18220        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18221        assert_eq!(
18222            types,
18223            vec!["table", "paragraph"],
18224            "Paragraph after table was dropped: got {types:?}"
18225        );
18226    }
18227
18228    #[test]
18229    fn extension_after_directive_table_is_preserved() {
18230        // Issue #361: extension after table
18231        let md = "\
18232::::table{layout=default}
18233:::tr
18234:::th{}
18235Header
18236:::
18237:::
18238::::
18239
18240::extension{type=com.atlassian.confluence.macro.core key=toc}";
18241        let adf = markdown_to_adf(md).unwrap();
18242        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18243        assert_eq!(
18244            types,
18245            vec!["table", "extension"],
18246            "Extension after table was dropped: got {types:?}"
18247        );
18248    }
18249
18250    #[test]
18251    fn multiple_blocks_after_directive_table() {
18252        // Issue #361: multiple blocks after table, including another table
18253        let md = "\
18254## Heading 1
18255
18256::::table{layout=default}
18257:::tr
18258:::td{}
18259A
18260:::
18261:::td{}
18262B
18263:::
18264:::
18265::::
18266
18267## Heading 2
18268
18269Some text.
18270
18271---
18272
18273::::table{layout=default}
18274:::tr
18275:::th{}
18276C
18277:::
18278:::
18279::::
18280
18281## Heading 3";
18282        let adf = markdown_to_adf(md).unwrap();
18283        let types: Vec<&str> = adf.content.iter().map(|n| n.node_type.as_str()).collect();
18284        assert_eq!(
18285            types,
18286            vec![
18287                "heading",
18288                "table",
18289                "heading",
18290                "paragraph",
18291                "rule",
18292                "table",
18293                "heading"
18294            ],
18295            "Content after tables was dropped: got {types:?}"
18296        );
18297    }
18298
18299    // ── Table caption tests (issue #382) ────────────────────────────
18300
18301    #[test]
18302    fn adf_table_caption_to_markdown() {
18303        let doc = AdfDocument {
18304            version: 1,
18305            doc_type: "doc".to_string(),
18306            content: vec![AdfNode::table(vec![
18307                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18308                    AdfNode::text("cell"),
18309                ])])]),
18310                AdfNode::caption(vec![AdfNode::text("Table caption")]),
18311            ])],
18312        };
18313        let md = adf_to_markdown(&doc).unwrap();
18314        assert!(
18315            md.contains("::::table"),
18316            "table with caption must use directive form"
18317        );
18318        assert!(
18319            md.contains(":::caption"),
18320            "caption directive missing, got: {md}"
18321        );
18322        assert!(
18323            md.contains("Table caption"),
18324            "caption text missing, got: {md}"
18325        );
18326    }
18327
18328    #[test]
18329    fn directive_table_caption_parses() {
18330        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nTable caption\n:::\n::::\n";
18331        let doc = markdown_to_adf(md).unwrap();
18332        let table = &doc.content[0];
18333        assert_eq!(table.node_type, "table");
18334        let children = table.content.as_ref().unwrap();
18335        assert_eq!(children.len(), 2, "expected row + caption");
18336        assert_eq!(children[0].node_type, "tableRow");
18337        assert_eq!(children[1].node_type, "caption");
18338        let caption_content = children[1].content.as_ref().unwrap();
18339        assert_eq!(caption_content[0].text.as_deref(), Some("Table caption"));
18340    }
18341
18342    #[test]
18343    fn table_caption_round_trip_from_adf_json() {
18344        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18345          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18346          {"type":"caption","content":[{"type":"text","text":"Table caption"}]}
18347        ]}]}"#;
18348        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18349        let md = adf_to_markdown(&doc).unwrap();
18350        assert!(md.contains("Table caption"), "caption text lost in ADF→JFM");
18351        let round_tripped = markdown_to_adf(&md).unwrap();
18352        let children = round_tripped.content[0].content.as_ref().unwrap();
18353        let caption = children.iter().find(|n| n.node_type == "caption");
18354        assert!(caption.is_some(), "caption lost on round-trip");
18355        let caption_text = caption.unwrap().content.as_ref().unwrap();
18356        assert_eq!(caption_text[0].text.as_deref(), Some("Table caption"));
18357    }
18358
18359    #[test]
18360    fn table_caption_with_inline_marks_round_trips() {
18361        let doc = AdfDocument {
18362            version: 1,
18363            doc_type: "doc".to_string(),
18364            content: vec![AdfNode::table(vec![
18365                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18366                    AdfNode::text("data"),
18367                ])])]),
18368                AdfNode::caption(vec![
18369                    AdfNode::text("Caption with "),
18370                    AdfNode::text_with_marks("bold", vec![AdfMark::strong()]),
18371                ]),
18372            ])],
18373        };
18374        let md = adf_to_markdown(&doc).unwrap();
18375        assert!(md.contains("**bold**"), "bold mark missing in caption");
18376        let round_tripped = markdown_to_adf(&md).unwrap();
18377        let caption = round_tripped.content[0]
18378            .content
18379            .as_ref()
18380            .unwrap()
18381            .iter()
18382            .find(|n| n.node_type == "caption")
18383            .expect("caption node missing after round-trip");
18384        let inlines = caption.content.as_ref().unwrap();
18385        let bold_node = inlines.iter().find(|n| {
18386            n.marks
18387                .as_ref()
18388                .is_some_and(|m| m.iter().any(|mk| mk.mark_type == "strong"))
18389        });
18390        assert!(bold_node.is_some(), "bold mark lost in caption round-trip");
18391    }
18392
18393    // ── table caption localId tests (issue #524) ──────────────────────
18394
18395    #[test]
18396    fn table_caption_localid_roundtrip() {
18397        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18398          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18399          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Table with localId"}]}
18400        ]}]}"#;
18401        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18402        let md = adf_to_markdown(&doc).unwrap();
18403        assert!(
18404            md.contains("localId=abcdef123456"),
18405            "table caption localId should appear in markdown: {md}"
18406        );
18407        let rt = markdown_to_adf(&md).unwrap();
18408        let caption = rt.content[0]
18409            .content
18410            .as_ref()
18411            .unwrap()
18412            .iter()
18413            .find(|n| n.node_type == "caption")
18414            .expect("caption should survive round-trip");
18415        assert_eq!(
18416            caption.attrs.as_ref().unwrap()["localId"],
18417            "abcdef123456",
18418            "table caption localId should round-trip"
18419        );
18420    }
18421
18422    #[test]
18423    fn table_caption_without_localid_unchanged() {
18424        let md = "::::table\n:::tr\n:::td\ncell\n:::\n:::\n:::caption\nPlain caption\n:::\n::::\n";
18425        let doc = markdown_to_adf(md).unwrap();
18426        let caption = doc.content[0]
18427            .content
18428            .as_ref()
18429            .unwrap()
18430            .iter()
18431            .find(|n| n.node_type == "caption")
18432            .unwrap();
18433        assert!(
18434            caption.attrs.is_none(),
18435            "table caption without localId should not gain attrs"
18436        );
18437        let md2 = adf_to_markdown(&doc).unwrap();
18438        assert!(!md2.contains("localId"), "no localId should appear: {md2}");
18439    }
18440
18441    #[test]
18442    fn table_caption_localid_stripped_when_option_set() {
18443        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},"content":[
18444          {"type":"tableRow","content":[{"type":"tableCell","attrs":{},"content":[{"type":"paragraph","content":[{"type":"text","text":"cell"}]}]}]},
18445          {"type":"caption","attrs":{"localId":"abcdef123456"},"content":[{"type":"text","text":"Stripped"}]}
18446        ]}]}"#;
18447        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18448        let opts = RenderOptions {
18449            strip_local_ids: true,
18450            ..Default::default()
18451        };
18452        let md = adf_to_markdown_with_options(&doc, &opts).unwrap();
18453        assert!(
18454            !md.contains("localId"),
18455            "table caption localId should be stripped: {md}"
18456        );
18457    }
18458
18459    #[test]
18460    #[test]
18461    fn tablecell_empty_attrs_preserved_on_roundtrip() {
18462        // Issue #385: tableCell with empty attrs:{} dropped on round-trip
18463        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"}]}]}]}]}]}"#;
18464        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18465        let md = adf_to_markdown(&doc).unwrap();
18466        let round_tripped = markdown_to_adf(&md).unwrap();
18467        let rows = round_tripped.content[0].content.as_ref().unwrap();
18468        let cell = &rows[0].content.as_ref().unwrap()[0];
18469        assert!(
18470            cell.attrs.is_some(),
18471            "tableCell attrs should be preserved, got None"
18472        );
18473        assert_eq!(
18474            cell.attrs.as_ref().unwrap(),
18475            &serde_json::json!({}),
18476            "tableCell attrs should be an empty object"
18477        );
18478    }
18479
18480    #[test]
18481    fn tablecell_empty_attrs_serialized_in_json() {
18482        // Issue #385: ensure the serialized JSON includes "attrs":{}
18483        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"}]}]}]}]}]}"#;
18484        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18485        let md = adf_to_markdown(&doc).unwrap();
18486        let round_tripped = markdown_to_adf(&md).unwrap();
18487        let json = serde_json::to_string(&round_tripped).unwrap();
18488        assert!(
18489            json.contains(r#""attrs":{}"#),
18490            "serialized JSON should contain \"attrs\":{{}}, got: {json}"
18491        );
18492    }
18493
18494    #[test]
18495    fn tablecell_empty_attrs_renders_braces_in_markdown() {
18496        // Issue #385: tableCell with empty attrs should render {} prefix in pipe tables
18497        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"}]}]}]}]}]}"#;
18498        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18499        let md = adf_to_markdown(&doc).unwrap();
18500        // Cell with attrs:{} should have {} prefix, cell without attrs should not
18501        assert!(
18502            md.contains("{} hello"),
18503            "cell with empty attrs should render '{{}} hello', got: {md}"
18504        );
18505        assert!(
18506            !md.contains("{} world"),
18507            "cell without attrs should not render '{{}}', got: {md}"
18508        );
18509    }
18510
18511    #[test]
18512    fn tablecell_no_attrs_unchanged_on_roundtrip() {
18513        // Ensure tableCell without attrs stays without attrs
18514        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"}]}]}]}]}]}"#;
18515        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18516        let md = adf_to_markdown(&doc).unwrap();
18517        let round_tripped = markdown_to_adf(&md).unwrap();
18518        let rows = round_tripped.content[0].content.as_ref().unwrap();
18519        let cell = &rows[0].content.as_ref().unwrap()[0];
18520        assert!(
18521            cell.attrs.is_none(),
18522            "tableCell without attrs should stay None, got: {:?}",
18523            cell.attrs
18524        );
18525    }
18526
18527    #[test]
18528    fn tablecell_nonempty_attrs_preserved_on_roundtrip() {
18529        // Ensure tableCell with non-empty attrs still works
18530        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"}]}]}]}]}]}"##;
18531        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18532        let md = adf_to_markdown(&doc).unwrap();
18533        let round_tripped = markdown_to_adf(&md).unwrap();
18534        let rows = round_tripped.content[0].content.as_ref().unwrap();
18535        let cell = &rows[1].content.as_ref().unwrap()[0];
18536        let attrs = cell.attrs.as_ref().unwrap();
18537        assert_eq!(attrs["background"], "#DEEBFF");
18538        assert_eq!(attrs["colspan"], 2);
18539    }
18540
18541    #[test]
18542    fn pipe_table_not_used_when_caption_present() {
18543        let doc = AdfDocument {
18544            version: 1,
18545            doc_type: "doc".to_string(),
18546            content: vec![AdfNode::table(vec![
18547                AdfNode::table_row(vec![AdfNode::table_header(vec![AdfNode::paragraph(vec![
18548                    AdfNode::text("H"),
18549                ])])]),
18550                AdfNode::table_row(vec![AdfNode::table_cell(vec![AdfNode::paragraph(vec![
18551                    AdfNode::text("D"),
18552                ])])]),
18553                AdfNode::caption(vec![AdfNode::text("cap")]),
18554            ])],
18555        };
18556        let md = adf_to_markdown(&doc).unwrap();
18557        assert!(
18558            md.contains("::::table"),
18559            "pipe syntax should not be used when caption is present"
18560        );
18561    }
18562
18563    // ── Issue #402: ordered-list-like text in list item hardBreak ──
18564
18565    #[test]
18566    fn hardbreak_with_ordered_marker_in_bullet_item_roundtrips() {
18567        // Issue #402: text starting with "2. " after a hardBreak inside a
18568        // bullet list item must not be re-parsed as a new ordered list.
18569        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18570          {"type":"listItem","content":[{"type":"paragraph","content":[
18571            {"type":"text","text":"1. First item"},
18572            {"type":"hardBreak"},
18573            {"type":"text","text":"2. Honouring existing commitments"}
18574          ]}]}
18575        ]}]}"#;
18576        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18577        let md = adf_to_markdown(&doc).unwrap();
18578
18579        // The continuation line must be indented so it stays within the list item.
18580        assert!(
18581            md.contains("  2. Honouring"),
18582            "Continuation line should be indented, got:\n{md}"
18583        );
18584
18585        // Round-trip back to ADF
18586        let rt = markdown_to_adf(&md).unwrap();
18587        let list = &rt.content[0];
18588        assert_eq!(list.node_type, "bulletList");
18589        let items = list.content.as_ref().unwrap();
18590        assert_eq!(
18591            items.len(),
18592            1,
18593            "Should be one list item, got {}",
18594            items.len()
18595        );
18596
18597        let para = &items[0].content.as_ref().unwrap()[0];
18598        let inlines = para.content.as_ref().unwrap();
18599        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18600        assert_eq!(
18601            types,
18602            vec!["text", "hardBreak", "text"],
18603            "Expected text+hardBreak+text, got {types:?}"
18604        );
18605        assert_eq!(
18606            inlines[2].text.as_deref().unwrap(),
18607            "2. Honouring existing commitments"
18608        );
18609    }
18610
18611    #[test]
18612    fn hardbreak_with_ordered_marker_in_ordered_item_roundtrips() {
18613        // Same as above but inside an ordered list.
18614        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18615          {"type":"listItem","content":[{"type":"paragraph","content":[
18616            {"type":"text","text":"Introduction  "},
18617            {"type":"hardBreak"},
18618            {"type":"text","text":"3. Third point"}
18619          ]}]}
18620        ]}]}"#;
18621        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18622        let md = adf_to_markdown(&doc).unwrap();
18623        let rt = markdown_to_adf(&md).unwrap();
18624
18625        let list = &rt.content[0];
18626        assert_eq!(list.node_type, "orderedList");
18627        let items = list.content.as_ref().unwrap();
18628        assert_eq!(items.len(), 1);
18629
18630        let para = &items[0].content.as_ref().unwrap()[0];
18631        let inlines = para.content.as_ref().unwrap();
18632        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18633        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18634        assert_eq!(inlines[2].text.as_deref().unwrap(), "3. Third point");
18635    }
18636
18637    #[test]
18638    fn hardbreak_with_bullet_marker_in_bullet_item_roundtrips() {
18639        // Text starting with "- " after a hardBreak must not become a nested bullet list.
18640        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18641          {"type":"listItem","content":[{"type":"paragraph","content":[
18642            {"type":"text","text":"Header  "},
18643            {"type":"hardBreak"},
18644            {"type":"text","text":"- not a sub-item"}
18645          ]}]}
18646        ]}]}"#;
18647        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18648        let md = adf_to_markdown(&doc).unwrap();
18649        let rt = markdown_to_adf(&md).unwrap();
18650
18651        let list = &rt.content[0];
18652        assert_eq!(list.node_type, "bulletList");
18653        let items = list.content.as_ref().unwrap();
18654        assert_eq!(
18655            items.len(),
18656            1,
18657            "Should be one list item, not {}",
18658            items.len()
18659        );
18660
18661        let para = &items[0].content.as_ref().unwrap()[0];
18662        let inlines = para.content.as_ref().unwrap();
18663        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18664        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18665        assert_eq!(inlines[2].text.as_deref().unwrap(), "- not a sub-item");
18666    }
18667
18668    #[test]
18669    fn hardbreak_continuation_followed_by_sub_list() {
18670        // A hardBreak continuation line followed by a real sub-list.
18671        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18672          {"type":"listItem","content":[
18673            {"type":"paragraph","content":[
18674              {"type":"text","text":"Main item  "},
18675              {"type":"hardBreak"},
18676              {"type":"text","text":"continued here"}
18677            ]},
18678            {"type":"bulletList","content":[
18679              {"type":"listItem","content":[{"type":"paragraph","content":[
18680                {"type":"text","text":"sub-item"}
18681              ]}]}
18682            ]}
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        let items = list.content.as_ref().unwrap();
18691        assert_eq!(items.len(), 1);
18692
18693        let item_content = items[0].content.as_ref().unwrap();
18694        assert_eq!(item_content.len(), 2, "Expected paragraph + nested list");
18695        assert_eq!(item_content[0].node_type, "paragraph");
18696        assert_eq!(item_content[1].node_type, "bulletList");
18697
18698        // Check the paragraph has hardBreak
18699        let inlines = item_content[0].content.as_ref().unwrap();
18700        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18701        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18702    }
18703
18704    #[test]
18705    fn multiple_hardbreaks_with_numbered_text_roundtrip() {
18706        // Multiple hardBreaks where each continuation resembles an ordered list.
18707        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18708          {"type":"listItem","content":[{"type":"paragraph","content":[
18709            {"type":"text","text":"Preamble  "},
18710            {"type":"hardBreak"},
18711            {"type":"text","text":"1. Alpha  "},
18712            {"type":"hardBreak"},
18713            {"type":"text","text":"2. Bravo"}
18714          ]}]}
18715        ]}]}"#;
18716        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18717        let md = adf_to_markdown(&doc).unwrap();
18718        let rt = markdown_to_adf(&md).unwrap();
18719
18720        let items = rt.content[0].content.as_ref().unwrap();
18721        assert_eq!(items.len(), 1);
18722
18723        let inlines = items[0].content.as_ref().unwrap()[0]
18724            .content
18725            .as_ref()
18726            .unwrap();
18727        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18728        assert_eq!(
18729            types,
18730            vec!["text", "hardBreak", "text", "hardBreak", "text"]
18731        );
18732    }
18733
18734    #[test]
18735    fn trailing_hardbreak_in_bullet_item_roundtrips() {
18736        // A hardBreak as the last inline node with no text after it.
18737        // Exercises the `break` path in the continuation loop and the
18738        // empty-line rendering branch.
18739        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18740          {"type":"listItem","content":[{"type":"paragraph","content":[
18741            {"type":"text","text":"ends with break"},
18742            {"type":"hardBreak"}
18743          ]}]}
18744        ]}]}"#;
18745        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18746        let md = adf_to_markdown(&doc).unwrap();
18747        let rt = markdown_to_adf(&md).unwrap();
18748
18749        let list = &rt.content[0];
18750        assert_eq!(list.node_type, "bulletList");
18751        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18752            .content
18753            .as_ref()
18754            .unwrap();
18755        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18756        assert_eq!(types, vec!["text", "hardBreak"]);
18757    }
18758
18759    #[test]
18760    fn trailing_hardbreak_in_ordered_item_roundtrips() {
18761        // Same as above but in an ordered list, covering the ordered-list
18762        // continuation `break` path.
18763        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
18764          {"type":"listItem","content":[{"type":"paragraph","content":[
18765            {"type":"text","text":"ends with break"},
18766            {"type":"hardBreak"}
18767          ]}]}
18768        ]}]}"#;
18769        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18770        let md = adf_to_markdown(&doc).unwrap();
18771        let rt = markdown_to_adf(&md).unwrap();
18772
18773        let list = &rt.content[0];
18774        assert_eq!(list.node_type, "orderedList");
18775        let inlines = list.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0]
18776            .content
18777            .as_ref()
18778            .unwrap();
18779        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18780        assert_eq!(types, vec!["text", "hardBreak"]);
18781    }
18782
18783    #[test]
18784    fn trailing_space_hardbreak_continuation_in_bullet_item() {
18785        // Exercises the `ends_with("  ")` path in `has_trailing_hard_break`
18786        // by parsing hand-written markdown that uses trailing-space style
18787        // hardBreaks instead of backslash style.
18788        let md = "- first line  \n  2. continued\n";
18789        let doc = markdown_to_adf(md).unwrap();
18790
18791        let list = &doc.content[0];
18792        assert_eq!(list.node_type, "bulletList");
18793        let items = list.content.as_ref().unwrap();
18794        assert_eq!(
18795            items.len(),
18796            1,
18797            "Should be one list item, got {}",
18798            items.len()
18799        );
18800
18801        let para = &items[0].content.as_ref().unwrap()[0];
18802        let inlines = para.content.as_ref().unwrap();
18803        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18804        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18805        assert_eq!(inlines[2].text.as_deref().unwrap(), "2. continued");
18806    }
18807
18808    #[test]
18809    fn trailing_space_hardbreak_continuation_in_ordered_item() {
18810        // Same as above but for ordered list, exercising the trailing-space
18811        // path in the ordered-list continuation loop.
18812        let md = "1. first line  \n  - continued\n";
18813        let doc = markdown_to_adf(md).unwrap();
18814
18815        let list = &doc.content[0];
18816        assert_eq!(list.node_type, "orderedList");
18817        let items = list.content.as_ref().unwrap();
18818        assert_eq!(
18819            items.len(),
18820            1,
18821            "Should be one list item, got {}",
18822            items.len()
18823        );
18824
18825        let para = &items[0].content.as_ref().unwrap()[0];
18826        let inlines = para.content.as_ref().unwrap();
18827        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
18828        assert_eq!(types, vec!["text", "hardBreak", "text"]);
18829        assert_eq!(inlines[2].text.as_deref().unwrap(), "- continued");
18830    }
18831
18832    #[test]
18833    fn multi_paragraph_list_item_with_ordered_marker_roundtrips() {
18834        // Issue #402 comment: a listItem with a second paragraph starting
18835        // with "2. " must not become a separate orderedList.
18836        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18837          {"type":"listItem","content":[
18838            {"type":"paragraph","content":[{"type":"text","text":"some preamble"}]},
18839            {"type":"paragraph","content":[{"type":"text","text":"2. Honouring existing commitments"}]}
18840          ]}
18841        ]}]}"#;
18842        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18843        let md = adf_to_markdown(&doc).unwrap();
18844        let rt = markdown_to_adf(&md).unwrap();
18845
18846        assert_eq!(rt.content.len(), 1, "Should be one top-level block");
18847        let list = &rt.content[0];
18848        assert_eq!(list.node_type, "bulletList");
18849        let items = list.content.as_ref().unwrap();
18850        assert_eq!(items.len(), 1);
18851        let item_content = items[0].content.as_ref().unwrap();
18852        assert_eq!(
18853            item_content.len(),
18854            2,
18855            "Expected 2 paragraphs inside the list item, got {}",
18856            item_content.len()
18857        );
18858        assert_eq!(item_content[0].node_type, "paragraph");
18859        assert_eq!(item_content[1].node_type, "paragraph");
18860        let text = item_content[1].content.as_ref().unwrap()[0]
18861            .text
18862            .as_deref()
18863            .unwrap();
18864        assert_eq!(text, "2. Honouring existing commitments");
18865    }
18866
18867    #[test]
18868    fn multi_paragraph_list_item_with_bullet_marker_roundtrips() {
18869        // Paragraph starting with "- " inside a list item.
18870        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
18871          {"type":"listItem","content":[
18872            {"type":"paragraph","content":[{"type":"text","text":"preamble"}]},
18873            {"type":"paragraph","content":[{"type":"text","text":"- not a sub-item"}]}
18874          ]}
18875        ]}]}"#;
18876        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
18877        let md = adf_to_markdown(&doc).unwrap();
18878        let rt = markdown_to_adf(&md).unwrap();
18879
18880        let items = rt.content[0].content.as_ref().unwrap();
18881        assert_eq!(items.len(), 1);
18882        let item_content = items[0].content.as_ref().unwrap();
18883        assert_eq!(item_content.len(), 2);
18884        assert_eq!(item_content[1].node_type, "paragraph");
18885        let text = item_content[1].content.as_ref().unwrap()[0]
18886            .text
18887            .as_deref()
18888            .unwrap();
18889        assert_eq!(text, "- not a sub-item");
18890    }
18891
18892    #[test]
18893    fn backslash_escape_in_inline_text() {
18894        // Verify that `\. ` is unescaped to `. ` in inline parsing.
18895        let nodes = parse_inline(r"2\. text");
18896        assert_eq!(nodes.len(), 1, "Should be one text node");
18897        assert_eq!(nodes[0].text.as_deref().unwrap(), "2. text");
18898    }
18899
18900    #[test]
18901    fn escape_list_marker_ordered() {
18902        assert_eq!(escape_list_marker("2. text"), r"2\. text");
18903        assert_eq!(escape_list_marker("10. tenth"), r"10\. tenth");
18904    }
18905
18906    #[test]
18907    fn escape_list_marker_bullet() {
18908        assert_eq!(escape_list_marker("- text"), r"\- text");
18909        assert_eq!(escape_list_marker("* text"), r"\* text");
18910        assert_eq!(escape_list_marker("+ text"), r"\+ text");
18911    }
18912
18913    #[test]
18914    fn escape_list_marker_plain() {
18915        assert_eq!(escape_list_marker("plain text"), "plain text");
18916        assert_eq!(escape_list_marker("no. marker"), "no. marker");
18917    }
18918
18919    #[test]
18920    fn escape_emoji_shortcodes_basic() {
18921        assert_eq!(escape_emoji_shortcodes(":fire:"), r"\:fire:");
18922        assert_eq!(
18923            escape_emoji_shortcodes("hello :wave: world"),
18924            r"hello \:wave: world"
18925        );
18926    }
18927
18928    #[test]
18929    fn escape_emoji_shortcodes_double_colon() {
18930        // Only the colon that starts `:Active:` needs escaping
18931        assert_eq!(
18932            escape_emoji_shortcodes("Status::Active::Running"),
18933            r"Status:\:Active::Running"
18934        );
18935    }
18936
18937    #[test]
18938    fn escape_emoji_shortcodes_no_match() {
18939        // Lone colons, numeric-only between colons like 10:30
18940        assert_eq!(escape_emoji_shortcodes("Time is 10:30"), "Time is 10:30");
18941        assert_eq!(escape_emoji_shortcodes("no colons here"), "no colons here");
18942        assert_eq!(escape_emoji_shortcodes("trailing:"), "trailing:");
18943        assert_eq!(escape_emoji_shortcodes(":"), ":");
18944    }
18945
18946    #[test]
18947    fn escape_emoji_shortcodes_mixed() {
18948        assert_eq!(
18949            escape_emoji_shortcodes("Alert :fire: on pod:pod42"),
18950            r"Alert \:fire: on pod:pod42"
18951        );
18952    }
18953
18954    #[test]
18955    fn escape_emoji_shortcodes_unicode() {
18956        // Issue #552: Unicode alphanumeric chars must be escaped to match
18957        // `try_parse_emoji_shortcode`, which uses `is_alphanumeric` (not the
18958        // ASCII-only variant).  Without this, `:Café:` rendered un-escaped
18959        // would be re-parsed as an emoji on round-trip.
18960        assert_eq!(escape_emoji_shortcodes(":Café:"), r"\:Café:");
18961        assert_eq!(escape_emoji_shortcodes(":über:"), r"\:über:");
18962        assert_eq!(escape_emoji_shortcodes(":配置:"), r"\:配置:");
18963        assert_eq!(
18964            escape_emoji_shortcodes("ZBC::配置::Production"),
18965            r"ZBC:\:配置::Production"
18966        );
18967    }
18968
18969    #[test]
18970    fn escape_emoji_shortcodes_mixed_script_name() {
18971        // Issue #552: A name that mixes ASCII and Unicode alphanumerics is
18972        // still a single valid shortcode under `is_alphanumeric`.
18973        assert_eq!(escape_emoji_shortcodes(":abc配置:"), r"\:abc配置:");
18974        assert_eq!(escape_emoji_shortcodes(":配置abc:"), r"\:配置abc:");
18975    }
18976
18977    #[test]
18978    fn escape_emoji_shortcodes_unicode_followed_by_non_colon() {
18979        // `:Café world:` — `Café` is alphanumeric but the terminator is a
18980        // space, not `:`, so the `after + name_end < text.len()` path is
18981        // exercised but the final `== b':'` check bails out and nothing
18982        // gets escaped.  Guards the negative branch of the predicate.
18983        assert_eq!(escape_emoji_shortcodes(":Café world:"), ":Café world:");
18984    }
18985
18986    #[test]
18987    fn escape_emoji_shortcodes_name_runs_to_end() {
18988        // Colon followed by alphanumerics to end of string: `.find(...)` returns
18989        // `None`, so `name_end` falls back to the full remaining length via
18990        // `map_or`.  The `after + name_end < text.len()` check then fails and
18991        // nothing is escaped.  Exercises the `map_or` default branch for both
18992        // ASCII and Unicode names.
18993        assert_eq!(escape_emoji_shortcodes(":abc"), ":abc");
18994        assert_eq!(escape_emoji_shortcodes(":配置"), ":配置");
18995    }
18996
18997    #[test]
18998    fn unicode_shortcode_pattern_text_round_trips_as_text() {
18999        // Issue #552: A text node containing `:Café:` must round-trip as text,
19000        // not be split into text + emoji nodes.
19001        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19002          {"type":"text","text":"Visit :Café: today"}
19003        ]}]}"#;
19004        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19005
19006        let md = adf_to_markdown(&doc).unwrap();
19007        let round_tripped = markdown_to_adf(&md).unwrap();
19008        let content = round_tripped.content[0].content.as_ref().unwrap();
19009
19010        assert_eq!(
19011            content.len(),
19012            1,
19013            "should be a single text node, got: {content:?}"
19014        );
19015        assert_eq!(content[0].node_type, "text");
19016        assert_eq!(content[0].text.as_deref().unwrap(), "Visit :Café: today");
19017    }
19018
19019    #[test]
19020    fn unicode_double_colon_pattern_text_round_trips() {
19021        // Issue #552: `ZBC::配置::Production` should round-trip without splitting.
19022        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19023          {"type":"text","text":"Use ZBC::配置::Production for prod"}
19024        ]}]}"#;
19025        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19026
19027        let md = adf_to_markdown(&doc).unwrap();
19028        let round_tripped = markdown_to_adf(&md).unwrap();
19029        let content = round_tripped.content[0].content.as_ref().unwrap();
19030
19031        assert_eq!(
19032            content.len(),
19033            1,
19034            "should be a single text node, got: {content:?}"
19035        );
19036        assert_eq!(
19037            content[0].text.as_deref().unwrap(),
19038            "Use ZBC::配置::Production for prod"
19039        );
19040    }
19041
19042    #[test]
19043    fn merge_adjacent_text_nodes() {
19044        let mut nodes = vec![AdfNode::text("a"), AdfNode::text("b"), AdfNode::text("c")];
19045        merge_adjacent_text(&mut nodes);
19046        assert_eq!(nodes.len(), 1);
19047        assert_eq!(nodes[0].text.as_deref().unwrap(), "abc");
19048    }
19049
19050    // ── Issue #455: text after hardBreak in paragraph re-parsed as list ──
19051
19052    #[test]
19053    fn issue_455_paragraph_hardbreak_ordered_marker_roundtrips() {
19054        // Issue #455: "1. text" after a hardBreak in a paragraph must not
19055        // become an ordered list.
19056        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19057          {"type":"text","text":"Introduction: "},
19058          {"type":"hardBreak"},
19059          {"type":"text","text":"1. This text follows a hardBreak"}
19060        ]}]}"#;
19061        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19062        let md = adf_to_markdown(&doc).unwrap();
19063        let rt = markdown_to_adf(&md).unwrap();
19064
19065        assert_eq!(rt.content.len(), 1, "Should remain one block");
19066        assert_eq!(rt.content[0].node_type, "paragraph");
19067        let inlines = rt.content[0].content.as_ref().unwrap();
19068        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19069        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19070        assert_eq!(
19071            inlines[2].text.as_deref(),
19072            Some("1. This text follows a hardBreak")
19073        );
19074    }
19075
19076    #[test]
19077    fn issue_455_paragraph_hardbreak_bullet_marker_roundtrips() {
19078        // Issue #455 variant: "- text" after a hardBreak in a paragraph.
19079        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19080          {"type":"text","text":"Intro"},
19081          {"type":"hardBreak"},
19082          {"type":"text","text":"- not a list item"}
19083        ]}]}"#;
19084        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19085        let md = adf_to_markdown(&doc).unwrap();
19086        let rt = markdown_to_adf(&md).unwrap();
19087
19088        assert_eq!(rt.content.len(), 1);
19089        assert_eq!(rt.content[0].node_type, "paragraph");
19090        let inlines = rt.content[0].content.as_ref().unwrap();
19091        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19092        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19093        assert_eq!(inlines[2].text.as_deref(), Some("- not a list item"));
19094    }
19095
19096    #[test]
19097    fn issue_455_paragraph_hardbreak_heading_marker_roundtrips() {
19098        // Issue #455 variant: "# text" after a hardBreak in a paragraph.
19099        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19100          {"type":"text","text":"Intro"},
19101          {"type":"hardBreak"},
19102          {"type":"text","text":"# not a heading"}
19103        ]}]}"##;
19104        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19105        let md = adf_to_markdown(&doc).unwrap();
19106        let rt = markdown_to_adf(&md).unwrap();
19107
19108        assert_eq!(rt.content.len(), 1);
19109        assert_eq!(rt.content[0].node_type, "paragraph");
19110        let inlines = rt.content[0].content.as_ref().unwrap();
19111        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19112        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19113        assert_eq!(inlines[2].text.as_deref(), Some("# not a heading"));
19114    }
19115
19116    #[test]
19117    fn issue_455_paragraph_hardbreak_blockquote_marker_roundtrips() {
19118        // Issue #455 variant: "> text" after a hardBreak in a paragraph.
19119        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19120          {"type":"text","text":"Intro"},
19121          {"type":"hardBreak"},
19122          {"type":"text","text":"> not a blockquote"}
19123        ]}]}"#;
19124        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19125        let md = adf_to_markdown(&doc).unwrap();
19126        let rt = markdown_to_adf(&md).unwrap();
19127
19128        assert_eq!(rt.content.len(), 1);
19129        assert_eq!(rt.content[0].node_type, "paragraph");
19130        let inlines = rt.content[0].content.as_ref().unwrap();
19131        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19132        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19133        assert_eq!(inlines[2].text.as_deref(), Some("> not a blockquote"));
19134    }
19135
19136    #[test]
19137    fn issue_455_paragraph_multiple_hardbreaks_with_ordered_markers() {
19138        // Multiple hardBreaks in a paragraph, each followed by "N. text".
19139        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19140          {"type":"text","text":"Preamble"},
19141          {"type":"hardBreak"},
19142          {"type":"text","text":"1. First"},
19143          {"type":"hardBreak"},
19144          {"type":"text","text":"2. Second"},
19145          {"type":"hardBreak"},
19146          {"type":"text","text":"3. Third"}
19147        ]}]}"#;
19148        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19149        let md = adf_to_markdown(&doc).unwrap();
19150        let rt = markdown_to_adf(&md).unwrap();
19151
19152        assert_eq!(rt.content.len(), 1);
19153        assert_eq!(rt.content[0].node_type, "paragraph");
19154        let inlines = rt.content[0].content.as_ref().unwrap();
19155        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19156        assert_eq!(
19157            types,
19158            vec![
19159                "text",
19160                "hardBreak",
19161                "text",
19162                "hardBreak",
19163                "text",
19164                "hardBreak",
19165                "text"
19166            ]
19167        );
19168        assert_eq!(inlines[2].text.as_deref(), Some("1. First"));
19169        assert_eq!(inlines[4].text.as_deref(), Some("2. Second"));
19170        assert_eq!(inlines[6].text.as_deref(), Some("3. Third"));
19171    }
19172
19173    #[test]
19174    fn issue_455_paragraph_hardbreak_jfm_indentation() {
19175        // Verify that ADF→JFM output indents continuation lines.
19176        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19177          {"type":"text","text":"Intro"},
19178          {"type":"hardBreak"},
19179          {"type":"text","text":"1. continued"}
19180        ]}]}"#;
19181        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19182        let md = adf_to_markdown(&doc).unwrap();
19183        assert!(
19184            md.contains("Intro\\\n  1. continued"),
19185            "Continuation should be 2-space-indented, got: {md:?}"
19186        );
19187    }
19188
19189    #[test]
19190    fn issue_455_paragraph_hardbreak_from_jfm() {
19191        // Verify that JFM with 2-space-indented continuation is parsed
19192        // back as a single paragraph with hardBreak.
19193        let md = "Intro\\\n  1. This is continuation text\n";
19194        let doc = markdown_to_adf(md).unwrap();
19195
19196        assert_eq!(doc.content.len(), 1);
19197        assert_eq!(doc.content[0].node_type, "paragraph");
19198        let inlines = doc.content[0].content.as_ref().unwrap();
19199        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19200        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19201        assert_eq!(
19202            inlines[2].text.as_deref(),
19203            Some("1. This is continuation text")
19204        );
19205    }
19206
19207    #[test]
19208    fn issue_455_paragraph_starts_with_ordered_marker_and_hardbreak() {
19209        // Coverage: first line IS a list marker AND paragraph has hardBreaks.
19210        // Exercises the escape_list_marker path on the first line of a
19211        // multi-line paragraph buf in the rendering code.
19212        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
19213          {"type":"text","text":"1. Starting with a number"},
19214          {"type":"hardBreak"},
19215          {"type":"text","text":"continuation after break"}
19216        ]}]}"#;
19217        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19218        let md = adf_to_markdown(&doc).unwrap();
19219        // First line should be escaped so it's not parsed as ordered list
19220        assert!(
19221            md.contains(r"1\. Starting with a number"),
19222            "First line should have escaped list marker, got: {md:?}"
19223        );
19224        let rt = markdown_to_adf(&md).unwrap();
19225
19226        assert_eq!(rt.content.len(), 1);
19227        assert_eq!(rt.content[0].node_type, "paragraph");
19228        let inlines = rt.content[0].content.as_ref().unwrap();
19229        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19230        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19231        assert_eq!(
19232            inlines[0].text.as_deref(),
19233            Some("1. Starting with a number")
19234        );
19235        assert_eq!(inlines[2].text.as_deref(), Some("continuation after break"));
19236    }
19237
19238    #[test]
19239    fn ordered_marker_paragraph_in_table_cell_roundtrips() {
19240        // Issue #402: paragraph with "2. " text inside a tableCell must
19241        // not be re-parsed as an ordered list.
19242        let adf_json = r#"{"version":1,"type":"doc","content":[{
19243          "type":"table","attrs":{"isNumberColumnEnabled":false,"layout":"default"},
19244          "content":[{"type":"tableRow","content":[{
19245            "type":"tableCell","attrs":{"colspan":1,"rowspan":1},
19246            "content":[{"type":"paragraph","content":[
19247              {"type":"text","text":"2. Honouring existing commitments"}
19248            ]}]
19249          }]}]
19250        }]}"#;
19251        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19252        let md = adf_to_markdown(&doc).unwrap();
19253        let rt = markdown_to_adf(&md).unwrap();
19254
19255        let table = &rt.content[0];
19256        let cell = &table.content.as_ref().unwrap()[0].content.as_ref().unwrap()[0];
19257        let para = &cell.content.as_ref().unwrap()[0];
19258        assert_eq!(para.node_type, "paragraph");
19259        let text = para.content.as_ref().unwrap()[0].text.as_deref().unwrap();
19260        assert_eq!(text, "2. Honouring existing commitments");
19261    }
19262
19263    #[test]
19264    fn bullet_marker_paragraph_standalone_roundtrips() {
19265        // A top-level paragraph starting with "- " must round-trip as
19266        // a paragraph, not a bullet list.
19267        let adf_json = r#"{"version":1,"type":"doc","content":[
19268          {"type":"paragraph","content":[
19269            {"type":"text","text":"- not a list item"}
19270          ]}
19271        ]}"#;
19272        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19273        let md = adf_to_markdown(&doc).unwrap();
19274        assert!(
19275            md.contains(r"\- not a list item"),
19276            "Should escape the leading dash, got:\n{md}"
19277        );
19278        let rt = markdown_to_adf(&md).unwrap();
19279        assert_eq!(rt.content[0].node_type, "paragraph");
19280        let text = rt.content[0].content.as_ref().unwrap()[0]
19281            .text
19282            .as_deref()
19283            .unwrap();
19284        assert_eq!(text, "- not a list item");
19285    }
19286
19287    #[test]
19288    fn merge_adjacent_text_skips_non_text_nodes() {
19289        // Exercises the `else { i += 1 }` branch when adjacent nodes
19290        // are not both plain text.
19291        let mut nodes = vec![
19292            AdfNode::text("a"),
19293            AdfNode::hard_break(),
19294            AdfNode::text("b"),
19295        ];
19296        merge_adjacent_text(&mut nodes);
19297        assert_eq!(nodes.len(), 3);
19298    }
19299
19300    #[test]
19301    fn star_bullet_paragraph_roundtrips() {
19302        // Paragraph starting with "* " must round-trip without becoming
19303        // a bullet list.
19304        let adf_json = r#"{"version":1,"type":"doc","content":[
19305          {"type":"paragraph","content":[
19306            {"type":"text","text":"* starred"}
19307          ]}
19308        ]}"#;
19309        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19310        let md = adf_to_markdown(&doc).unwrap();
19311        let rt = markdown_to_adf(&md).unwrap();
19312        assert_eq!(rt.content[0].node_type, "paragraph");
19313        assert_eq!(
19314            rt.content[0].content.as_ref().unwrap()[0]
19315                .text
19316                .as_deref()
19317                .unwrap(),
19318            "* starred"
19319        );
19320    }
19321
19322    // ---- Issue #388 tests ----
19323
19324    #[test]
19325    fn issue_388_ordered_list_with_strong_hardbreak_roundtrips() {
19326        // Issue #388: orderedList with 2 listItems, each containing
19327        // strong-marked text + hardBreak + plain text.
19328        let adf_json = r#"{"version":1,"type":"doc","content":[
19329          {"type":"orderedList","attrs":{"order":1},"content":[
19330            {"type":"listItem","content":[
19331              {"type":"paragraph","content":[
19332                {"type":"text","text":"Bold heading","marks":[{"type":"strong"}]},
19333                {"type":"hardBreak"},
19334                {"type":"text","text":"Content after break"}
19335              ]}
19336            ]},
19337            {"type":"listItem","content":[
19338              {"type":"paragraph","content":[
19339                {"type":"text","text":"Second item","marks":[{"type":"strong"}]},
19340                {"type":"hardBreak"},
19341                {"type":"text","text":"More content"}
19342              ]}
19343            ]}
19344          ]}
19345        ]}"#;
19346        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19347        let md = adf_to_markdown(&doc).unwrap();
19348        let rt = markdown_to_adf(&md).unwrap();
19349
19350        // Must remain a single orderedList
19351        assert_eq!(
19352            rt.content.len(),
19353            1,
19354            "Should be 1 block (orderedList), got {}",
19355            rt.content.len()
19356        );
19357        assert_eq!(rt.content[0].node_type, "orderedList");
19358        let items = rt.content[0].content.as_ref().unwrap();
19359        assert_eq!(
19360            items.len(),
19361            2,
19362            "Should have 2 listItems, got {}",
19363            items.len()
19364        );
19365
19366        // First item: text(strong) + hardBreak + text
19367        let p1 = items[0].content.as_ref().unwrap()[0]
19368            .content
19369            .as_ref()
19370            .unwrap();
19371        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19372        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19373        assert_eq!(p1[0].text.as_deref(), Some("Bold heading"));
19374        assert_eq!(p1[2].text.as_deref(), Some("Content after break"));
19375
19376        // Second item: text(strong) + hardBreak + text
19377        let p2 = items[1].content.as_ref().unwrap()[0]
19378            .content
19379            .as_ref()
19380            .unwrap();
19381        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19382        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19383        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
19384        assert_eq!(p2[2].text.as_deref(), Some("More content"));
19385    }
19386
19387    #[test]
19388    fn issue_388_bullet_list_with_strong_hardbreak_roundtrips() {
19389        // Bullet list variant of issue #388.
19390        let adf_json = r#"{"version":1,"type":"doc","content":[
19391          {"type":"bulletList","content":[
19392            {"type":"listItem","content":[
19393              {"type":"paragraph","content":[
19394                {"type":"text","text":"First","marks":[{"type":"strong"}]},
19395                {"type":"hardBreak"},
19396                {"type":"text","text":"details"}
19397              ]}
19398            ]},
19399            {"type":"listItem","content":[
19400              {"type":"paragraph","content":[
19401                {"type":"text","text":"Second","marks":[{"type":"em"}]},
19402                {"type":"hardBreak"},
19403                {"type":"text","text":"more details"}
19404              ]}
19405            ]}
19406          ]}
19407        ]}"#;
19408        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19409        let md = adf_to_markdown(&doc).unwrap();
19410        let rt = markdown_to_adf(&md).unwrap();
19411
19412        assert_eq!(rt.content.len(), 1);
19413        assert_eq!(rt.content[0].node_type, "bulletList");
19414        let items = rt.content[0].content.as_ref().unwrap();
19415        assert_eq!(items.len(), 2);
19416
19417        let p1 = items[0].content.as_ref().unwrap()[0]
19418            .content
19419            .as_ref()
19420            .unwrap();
19421        assert_eq!(p1[0].text.as_deref(), Some("First"));
19422        assert_eq!(p1[2].text.as_deref(), Some("details"));
19423
19424        let p2 = items[1].content.as_ref().unwrap()[0]
19425            .content
19426            .as_ref()
19427            .unwrap();
19428        assert_eq!(p2[0].text.as_deref(), Some("Second"));
19429        assert_eq!(p2[2].text.as_deref(), Some("more details"));
19430    }
19431
19432    #[test]
19433    fn issue_388_ordered_list_hardbreak_jfm_indentation() {
19434        // Verify the JFM output has properly indented continuation lines.
19435        let adf_json = r#"{"version":1,"type":"doc","content":[
19436          {"type":"orderedList","attrs":{"order":1},"content":[
19437            {"type":"listItem","content":[
19438              {"type":"paragraph","content":[
19439                {"type":"text","text":"heading","marks":[{"type":"strong"}]},
19440                {"type":"hardBreak"},
19441                {"type":"text","text":"body"}
19442              ]}
19443            ]}
19444          ]}
19445        ]}"#;
19446        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19447        let md = adf_to_markdown(&doc).unwrap();
19448        assert!(
19449            md.contains("1. **heading**\\\n  body"),
19450            "Continuation should be indented, got:\n{md}"
19451        );
19452    }
19453
19454    #[test]
19455    fn issue_388_ordered_list_hardbreak_from_jfm() {
19456        // Direct JFM → ADF: ordered list with hardBreak continuation.
19457        let md = "1. **bold**\\\n  continued\n2. **also bold**\\\n  also continued\n";
19458        let doc = markdown_to_adf(md).unwrap();
19459
19460        assert_eq!(doc.content.len(), 1);
19461        assert_eq!(doc.content[0].node_type, "orderedList");
19462        let items = doc.content[0].content.as_ref().unwrap();
19463        assert_eq!(items.len(), 2);
19464
19465        let p1 = items[0].content.as_ref().unwrap()[0]
19466            .content
19467            .as_ref()
19468            .unwrap();
19469        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
19470        assert_eq!(types1, vec!["text", "hardBreak", "text"]);
19471        assert_eq!(p1[0].text.as_deref(), Some("bold"));
19472        assert_eq!(p1[2].text.as_deref(), Some("continued"));
19473
19474        let p2 = items[1].content.as_ref().unwrap()[0]
19475            .content
19476            .as_ref()
19477            .unwrap();
19478        let types2: Vec<&str> = p2.iter().map(|n| n.node_type.as_str()).collect();
19479        assert_eq!(types2, vec!["text", "hardBreak", "text"]);
19480    }
19481
19482    #[test]
19483    fn issue_388_bullet_list_hardbreak_from_jfm() {
19484        // Direct JFM → ADF: bullet list with hardBreak continuation.
19485        let md = "- first\\\n  second\n- third\\\n  fourth\n";
19486        let doc = markdown_to_adf(md).unwrap();
19487
19488        assert_eq!(doc.content.len(), 1);
19489        assert_eq!(doc.content[0].node_type, "bulletList");
19490        let items = doc.content[0].content.as_ref().unwrap();
19491        assert_eq!(items.len(), 2);
19492
19493        for (i, expected) in [("first", "second"), ("third", "fourth")]
19494            .iter()
19495            .enumerate()
19496        {
19497            let p = items[i].content.as_ref().unwrap()[0]
19498                .content
19499                .as_ref()
19500                .unwrap();
19501            let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19502            assert_eq!(types, vec!["text", "hardBreak", "text"]);
19503            assert_eq!(p[0].text.as_deref(), Some(expected.0));
19504            assert_eq!(p[2].text.as_deref(), Some(expected.1));
19505        }
19506    }
19507
19508    #[test]
19509    fn issue_433_heading_hardbreak_roundtrips() {
19510        // Issue #433: hardBreak inside heading splits into heading + paragraph.
19511        let adf_json = r#"{"version":1,"type":"doc","content":[{
19512          "type":"heading",
19513          "attrs":{"level":1},
19514          "content":[
19515            {"type":"text","text":"Line one"},
19516            {"type":"hardBreak"},
19517            {"type":"text","text":"Line two"}
19518          ]
19519        }]}"#;
19520        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19521        let md = adf_to_markdown(&doc).unwrap();
19522        let rt = markdown_to_adf(&md).unwrap();
19523
19524        assert_eq!(
19525            rt.content.len(),
19526            1,
19527            "Should remain a single heading, got {} blocks",
19528            rt.content.len()
19529        );
19530        assert_eq!(rt.content[0].node_type, "heading");
19531        let inlines = rt.content[0].content.as_ref().unwrap();
19532        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19533        assert_eq!(
19534            types,
19535            vec!["text", "hardBreak", "text"],
19536            "hardBreak should be preserved, got: {types:?}"
19537        );
19538        assert_eq!(inlines[0].text.as_deref(), Some("Line one"));
19539        assert_eq!(inlines[2].text.as_deref(), Some("Line two"));
19540    }
19541
19542    #[test]
19543    fn issue_433_heading_hardbreak_jfm_indentation() {
19544        // Verify the JFM output has properly indented continuation lines.
19545        let adf_json = r#"{"version":1,"type":"doc","content":[{
19546          "type":"heading",
19547          "attrs":{"level":2},
19548          "content":[
19549            {"type":"text","text":"Title"},
19550            {"type":"hardBreak"},
19551            {"type":"text","text":"Subtitle"}
19552          ]
19553        }]}"#;
19554        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19555        let md = adf_to_markdown(&doc).unwrap();
19556        assert!(
19557            md.contains("## Title\\\n  Subtitle"),
19558            "Continuation should be indented, got:\n{md}"
19559        );
19560    }
19561
19562    #[test]
19563    fn issue_433_heading_hardbreak_from_jfm() {
19564        // Direct JFM → ADF: heading with hardBreak continuation.
19565        let md = "# First\\\n  Second\n";
19566        let doc = markdown_to_adf(md).unwrap();
19567
19568        assert_eq!(doc.content.len(), 1);
19569        assert_eq!(doc.content[0].node_type, "heading");
19570        let inlines = doc.content[0].content.as_ref().unwrap();
19571        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19572        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19573        assert_eq!(inlines[0].text.as_deref(), Some("First"));
19574        assert_eq!(inlines[2].text.as_deref(), Some("Second"));
19575    }
19576
19577    #[test]
19578    fn issue_433_heading_consecutive_hardbreaks_roundtrip() {
19579        // Consecutive hardBreaks in a heading.
19580        let adf_json = r#"{"version":1,"type":"doc","content":[{
19581          "type":"heading",
19582          "attrs":{"level":3},
19583          "content":[
19584            {"type":"text","text":"A"},
19585            {"type":"hardBreak"},
19586            {"type":"hardBreak"},
19587            {"type":"text","text":"B"}
19588          ]
19589        }]}"#;
19590        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19591        let md = adf_to_markdown(&doc).unwrap();
19592        let rt = markdown_to_adf(&md).unwrap();
19593
19594        assert_eq!(rt.content.len(), 1, "Should remain a single heading");
19595        assert_eq!(rt.content[0].node_type, "heading");
19596        let inlines = rt.content[0].content.as_ref().unwrap();
19597        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19598        assert_eq!(types, vec!["text", "hardBreak", "hardBreak", "text"]);
19599    }
19600
19601    #[test]
19602    fn issue_433_heading_with_strong_and_hardbreak_roundtrips() {
19603        // Heading with strong-marked text + hardBreak + plain text.
19604        let adf_json = r#"{"version":1,"type":"doc","content":[{
19605          "type":"heading",
19606          "attrs":{"level":1},
19607          "content":[
19608            {"type":"text","text":"Bold title","marks":[{"type":"strong"}]},
19609            {"type":"hardBreak"},
19610            {"type":"text","text":"plain continuation"}
19611          ]
19612        }]}"#;
19613        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19614        let md = adf_to_markdown(&doc).unwrap();
19615        let rt = markdown_to_adf(&md).unwrap();
19616
19617        assert_eq!(rt.content.len(), 1);
19618        assert_eq!(rt.content[0].node_type, "heading");
19619        let inlines = rt.content[0].content.as_ref().unwrap();
19620        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19621        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19622        assert_eq!(inlines[0].text.as_deref(), Some("Bold title"));
19623        assert_eq!(inlines[2].text.as_deref(), Some("plain continuation"));
19624    }
19625
19626    #[test]
19627    fn issue_433_heading_with_link_and_hardbreak_roundtrips() {
19628        // Real-world pattern: heading with link + hardBreak + text.
19629        let adf_json = r#"{"version":1,"type":"doc","content":[{
19630          "type":"heading",
19631          "attrs":{"level":1},
19632          "content":[
19633            {"type":"text","text":"Click here","marks":[{"type":"link","attrs":{"href":"https://example.com"}}]},
19634            {"type":"hardBreak"},
19635            {"type":"text","text":"Subtitle text"}
19636          ]
19637        }]}"#;
19638        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19639        let md = adf_to_markdown(&doc).unwrap();
19640        let rt = markdown_to_adf(&md).unwrap();
19641
19642        assert_eq!(rt.content.len(), 1);
19643        assert_eq!(rt.content[0].node_type, "heading");
19644        let inlines = rt.content[0].content.as_ref().unwrap();
19645        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
19646        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19647        assert_eq!(inlines[2].text.as_deref(), Some("Subtitle text"));
19648    }
19649
19650    #[test]
19651    fn has_trailing_hard_break_backslash() {
19652        assert!(has_trailing_hard_break("text\\"));
19653        assert!(has_trailing_hard_break("**bold**\\"));
19654    }
19655
19656    #[test]
19657    fn has_trailing_hard_break_trailing_spaces() {
19658        assert!(has_trailing_hard_break("text  "));
19659        assert!(has_trailing_hard_break("word   "));
19660    }
19661
19662    #[test]
19663    fn has_trailing_hard_break_false() {
19664        assert!(!has_trailing_hard_break("plain text"));
19665        assert!(!has_trailing_hard_break("text "));
19666        assert!(!has_trailing_hard_break(""));
19667    }
19668
19669    #[test]
19670    fn collect_hardbreak_continuations_collects_indented() {
19671        // A line ending with `\` followed by 2-space-indented continuation.
19672        // Only one line is collected because the result no longer ends with `\`.
19673        let input = "first\\\n  second\n  third\n";
19674        let mut parser = MarkdownParser::new(input);
19675        parser.advance(); // skip first line
19676        let mut text = "first\\".to_string();
19677        parser.collect_hardbreak_continuations(&mut text);
19678        assert_eq!(text, "first\\\nsecond");
19679    }
19680
19681    #[test]
19682    fn collect_hardbreak_continuations_stops_at_non_indented() {
19683        let input = "first\\\nnot indented\n";
19684        let mut parser = MarkdownParser::new(input);
19685        parser.advance();
19686        let mut text = "first\\".to_string();
19687        parser.collect_hardbreak_continuations(&mut text);
19688        // Should NOT collect the non-indented line
19689        assert_eq!(text, "first\\");
19690    }
19691
19692    #[test]
19693    fn collect_hardbreak_continuations_no_trailing_break() {
19694        // If the text doesn't end with a hardBreak marker, nothing is collected.
19695        let input = "plain\n  indented\n";
19696        let mut parser = MarkdownParser::new(input);
19697        parser.advance();
19698        let mut text = "plain".to_string();
19699        parser.collect_hardbreak_continuations(&mut text);
19700        assert_eq!(text, "plain");
19701    }
19702
19703    #[test]
19704    fn collect_hardbreak_continuations_chained() {
19705        // Multiple continuation lines chained via repeated hardBreaks.
19706        let input = "a\\\n  b\\\n  c\\\n  d\n";
19707        let mut parser = MarkdownParser::new(input);
19708        parser.advance();
19709        let mut text = "a\\".to_string();
19710        parser.collect_hardbreak_continuations(&mut text);
19711        assert_eq!(text, "a\\\nb\\\nc\\\nd");
19712    }
19713
19714    #[test]
19715    fn collect_hardbreak_continuations_stops_before_image_line() {
19716        // An indented continuation that starts with `![` (mediaSingle syntax)
19717        // must NOT be swallowed as a paragraph continuation (issue #490).
19718        let input = "text\\\n  ![](url){type=file id=x}\n";
19719        let mut parser = MarkdownParser::new(input);
19720        parser.advance(); // skip first line
19721        let mut text = "text\\".to_string();
19722        parser.collect_hardbreak_continuations(&mut text);
19723        // The image line should NOT have been consumed.
19724        assert_eq!(text, "text\\");
19725        // Parser should still be on the image line (not past it).
19726        assert!(!parser.at_end());
19727        assert!(parser.current_line().contains("![](url)"));
19728    }
19729
19730    #[test]
19731    fn is_block_level_continuation_marker_positive_cases() {
19732        // Each marker that forces `collect_hardbreak_continuations` to stop.
19733        assert!(is_block_level_continuation_marker("![](url)"));
19734        assert!(is_block_level_continuation_marker("```ruby"));
19735        assert!(is_block_level_continuation_marker(":::panel{type=info}"));
19736    }
19737
19738    #[test]
19739    fn is_block_level_continuation_marker_negative_cases() {
19740        // Plain continuation text must NOT look like a block-level marker.
19741        assert!(!is_block_level_continuation_marker("plain text"));
19742        assert!(!is_block_level_continuation_marker("- nested item"));
19743        assert!(!is_block_level_continuation_marker("continuation\\"));
19744        assert!(!is_block_level_continuation_marker(""));
19745        // Double-colon `::` is not a container directive.
19746        assert!(!is_block_level_continuation_marker("::partial"));
19747        // Single backticks are inline code, not a fence.
19748        assert!(!is_block_level_continuation_marker("`inline`"));
19749    }
19750
19751    #[test]
19752    fn collect_hardbreak_continuations_stops_before_code_fence() {
19753        // Issue #552: An indented continuation that opens a fenced code block
19754        // must NOT be swallowed as a paragraph continuation — it has to stay
19755        // available for `try_code_block` on the next parse iteration.
19756        let input = "text\\\n  ```ruby\n  Foo::Bar::Baz\n  ```\n";
19757        let mut parser = MarkdownParser::new(input);
19758        parser.advance();
19759        let mut text = "text\\".to_string();
19760        parser.collect_hardbreak_continuations(&mut text);
19761        assert_eq!(text, "text\\");
19762        assert!(!parser.at_end());
19763        assert!(parser.current_line().starts_with("  ```"));
19764    }
19765
19766    #[test]
19767    fn collect_hardbreak_continuations_stops_before_container_directive() {
19768        // Issue #552: An indented continuation that opens a `:::` container
19769        // directive (panel, expand, etc.) must also stay available for the
19770        // directive parser.
19771        let input = "text\\\n  :::panel{type=info}\n  body\n  :::\n";
19772        let mut parser = MarkdownParser::new(input);
19773        parser.advance();
19774        let mut text = "text\\".to_string();
19775        parser.collect_hardbreak_continuations(&mut text);
19776        assert_eq!(text, "text\\");
19777        assert!(!parser.at_end());
19778        assert!(parser.current_line().contains(":::panel"));
19779    }
19780
19781    #[test]
19782    fn collect_hardbreak_continuations_stops_before_indented_code_fence() {
19783        // Variant: extra leading whitespace on the code-fence line (so the
19784        // stripped tail is `  ```` rather than a bare ` ``` `) must still be
19785        // recognised by the `trim_start().starts_with("```")` check.
19786        let input = "text\\\n     ```text\n     :fire:\n     ```\n";
19787        let mut parser = MarkdownParser::new(input);
19788        parser.advance();
19789        let mut text = "text\\".to_string();
19790        parser.collect_hardbreak_continuations(&mut text);
19791        assert_eq!(text, "text\\");
19792        assert!(!parser.at_end());
19793        assert!(parser.current_line().contains("```text"));
19794    }
19795
19796    #[test]
19797    fn ordered_list_with_sub_content_after_hardbreak() {
19798        // Exercises the sub-content collection loop in parse_ordered_list
19799        // (lines 339-347) with a hardBreak item that also has a nested list.
19800        let adf_json = r#"{"version":1,"type":"doc","content":[
19801          {"type":"orderedList","attrs":{"order":1},"content":[
19802            {"type":"listItem","content":[
19803              {"type":"paragraph","content":[
19804                {"type":"text","text":"parent"},
19805                {"type":"hardBreak"},
19806                {"type":"text","text":"continued"}
19807              ]},
19808              {"type":"bulletList","content":[
19809                {"type":"listItem","content":[
19810                  {"type":"paragraph","content":[
19811                    {"type":"text","text":"child"}
19812                  ]}
19813                ]}
19814              ]}
19815            ]}
19816          ]}
19817        ]}"#;
19818        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19819        let md = adf_to_markdown(&doc).unwrap();
19820        let rt = markdown_to_adf(&md).unwrap();
19821
19822        assert_eq!(rt.content.len(), 1);
19823        assert_eq!(rt.content[0].node_type, "orderedList");
19824        let item_content = rt.content[0].content.as_ref().unwrap()[0]
19825            .content
19826            .as_ref()
19827            .unwrap();
19828        // Paragraph with hardBreak
19829        let p = item_content[0].content.as_ref().unwrap();
19830        let types: Vec<&str> = p.iter().map(|n| n.node_type.as_str()).collect();
19831        assert_eq!(types, vec!["text", "hardBreak", "text"]);
19832        assert_eq!(p[0].text.as_deref(), Some("parent"));
19833        assert_eq!(p[2].text.as_deref(), Some("continued"));
19834        // Nested bullet list
19835        assert_eq!(item_content[1].node_type, "bulletList");
19836    }
19837
19838    #[test]
19839    fn render_list_item_content_no_content() {
19840        // A listItem with content: None should produce just a newline.
19841        let item = AdfNode {
19842            node_type: "listItem".to_string(),
19843            attrs: None,
19844            content: None,
19845            text: None,
19846            marks: None,
19847            local_id: None,
19848            parameters: None,
19849        };
19850        let mut output = String::new();
19851        let opts = RenderOptions::default();
19852        render_list_item_content(&item, &mut output, &opts);
19853        assert_eq!(output, "\n");
19854    }
19855
19856    #[test]
19857    fn render_list_item_content_empty_content() {
19858        // A listItem with content: Some(vec![]) should produce just a newline.
19859        let item = AdfNode::list_item(vec![]);
19860        let mut output = String::new();
19861        let opts = RenderOptions::default();
19862        render_list_item_content(&item, &mut output, &opts);
19863        assert_eq!(output, "\n");
19864    }
19865
19866    #[test]
19867    fn plus_bullet_paragraph_roundtrips() {
19868        // Paragraph starting with "+ " must round-trip without becoming
19869        // a bullet list.
19870        let adf_json = r#"{"version":1,"type":"doc","content":[
19871          {"type":"paragraph","content":[
19872            {"type":"text","text":"+ plus"}
19873          ]}
19874        ]}"#;
19875        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19876        let md = adf_to_markdown(&doc).unwrap();
19877        let rt = markdown_to_adf(&md).unwrap();
19878        assert_eq!(rt.content[0].node_type, "paragraph");
19879        assert_eq!(
19880            rt.content[0].content.as_ref().unwrap()[0]
19881                .text
19882                .as_deref()
19883                .unwrap(),
19884            "+ plus"
19885        );
19886    }
19887
19888    // ---- Issue #430 tests: mediaSingle inside listItem ----
19889
19890    #[test]
19891    fn issue_430_file_media_in_bullet_list_roundtrip() {
19892        // Issue #430: mediaSingle (type:file) as direct child of listItem
19893        // in a bulletList must survive round-trip.
19894        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19895          {"type":"listItem","content":[{
19896            "type":"mediaSingle",
19897            "attrs":{"layout":"center","width":1009,"widthType":"pixel"},
19898            "content":[{
19899              "type":"media",
19900              "attrs":{"collection":"contentId-123","height":576,"id":"00066e8e-554e-4d7e-af59-a0ef2888bdb6","type":"file","width":1009}
19901            }]
19902          }]}
19903        ]}]}"#;
19904        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19905        let md = adf_to_markdown(&doc).unwrap();
19906        let rt = markdown_to_adf(&md).unwrap();
19907
19908        let list = &rt.content[0];
19909        assert_eq!(list.node_type, "bulletList");
19910        let item = &list.content.as_ref().unwrap()[0];
19911        assert_eq!(item.node_type, "listItem");
19912        let ms = &item.content.as_ref().unwrap()[0];
19913        assert_eq!(ms.node_type, "mediaSingle");
19914        let ms_attrs = ms.attrs.as_ref().unwrap();
19915        assert_eq!(ms_attrs["layout"], "center");
19916        assert_eq!(ms_attrs["width"], 1009);
19917        assert_eq!(ms_attrs["widthType"], "pixel");
19918        let media = &ms.content.as_ref().unwrap()[0];
19919        assert_eq!(media.node_type, "media");
19920        let m_attrs = media.attrs.as_ref().unwrap();
19921        assert_eq!(m_attrs["type"], "file");
19922        assert_eq!(m_attrs["id"], "00066e8e-554e-4d7e-af59-a0ef2888bdb6");
19923        assert_eq!(m_attrs["collection"], "contentId-123");
19924        assert_eq!(m_attrs["height"], 576);
19925        assert_eq!(m_attrs["width"], 1009);
19926    }
19927
19928    #[test]
19929    fn issue_430_file_media_in_ordered_list_roundtrip() {
19930        // Same as above but inside an orderedList.
19931        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
19932          {"type":"listItem","content":[{
19933            "type":"mediaSingle",
19934            "attrs":{"layout":"center"},
19935            "content":[{
19936              "type":"media",
19937              "attrs":{"type":"file","id":"abc-123","collection":"contentId-456","height":100,"width":200}
19938            }]
19939          }]}
19940        ]}]}"#;
19941        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19942        let md = adf_to_markdown(&doc).unwrap();
19943        let rt = markdown_to_adf(&md).unwrap();
19944
19945        let list = &rt.content[0];
19946        assert_eq!(list.node_type, "orderedList");
19947        let item = &list.content.as_ref().unwrap()[0];
19948        assert_eq!(item.node_type, "listItem");
19949        let ms = &item.content.as_ref().unwrap()[0];
19950        assert_eq!(ms.node_type, "mediaSingle");
19951        let media = &ms.content.as_ref().unwrap()[0];
19952        assert_eq!(media.node_type, "media");
19953        let m_attrs = media.attrs.as_ref().unwrap();
19954        assert_eq!(m_attrs["type"], "file");
19955        assert_eq!(m_attrs["id"], "abc-123");
19956        assert_eq!(m_attrs["collection"], "contentId-456");
19957    }
19958
19959    #[test]
19960    fn issue_430_external_media_in_bullet_list_roundtrip() {
19961        // External image (type:external) inside a bullet list item.
19962        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19963          {"type":"listItem","content":[{
19964            "type":"mediaSingle",
19965            "attrs":{"layout":"center"},
19966            "content":[{
19967              "type":"media",
19968              "attrs":{"type":"external","url":"https://example.com/img.png","alt":"Photo"}
19969            }]
19970          }]}
19971        ]}]}"#;
19972        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
19973        let md = adf_to_markdown(&doc).unwrap();
19974        let rt = markdown_to_adf(&md).unwrap();
19975
19976        let list = &rt.content[0];
19977        assert_eq!(list.node_type, "bulletList");
19978        let item = &list.content.as_ref().unwrap()[0];
19979        let ms = &item.content.as_ref().unwrap()[0];
19980        assert_eq!(ms.node_type, "mediaSingle");
19981        let media = &ms.content.as_ref().unwrap()[0];
19982        assert_eq!(media.node_type, "media");
19983        let m_attrs = media.attrs.as_ref().unwrap();
19984        assert_eq!(m_attrs["type"], "external");
19985        assert_eq!(m_attrs["url"], "https://example.com/img.png");
19986    }
19987
19988    #[test]
19989    fn issue_430_media_with_paragraph_siblings_in_list_item() {
19990        // listItem containing a paragraph followed by a mediaSingle.
19991        // Both children must survive round-trip.
19992        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
19993          {"type":"listItem","content":[
19994            {"type":"paragraph","content":[{"type":"text","text":"Caption:"}]},
19995            {"type":"mediaSingle","attrs":{"layout":"center"},
19996             "content":[{"type":"media","attrs":{"type":"file","id":"img-001","collection":"col-1","height":50,"width":100}}]}
19997          ]}
19998        ]}]}"#;
19999        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20000        let md = adf_to_markdown(&doc).unwrap();
20001        let rt = markdown_to_adf(&md).unwrap();
20002
20003        let item = &rt.content[0].content.as_ref().unwrap()[0];
20004        let children = item.content.as_ref().unwrap();
20005        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20006        assert_eq!(children[0].node_type, "paragraph");
20007        assert_eq!(children[1].node_type, "mediaSingle");
20008        let media = &children[1].content.as_ref().unwrap()[0];
20009        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-001");
20010    }
20011
20012    #[test]
20013    fn issue_430_multiple_media_in_list_items() {
20014        // Multiple list items each containing mediaSingle.
20015        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20016          {"type":"listItem","content":[{
20017            "type":"mediaSingle","attrs":{"layout":"center"},
20018            "content":[{"type":"media","attrs":{"type":"file","id":"img-a","collection":"c1","height":10,"width":20}}]
20019          }]},
20020          {"type":"listItem","content":[{
20021            "type":"mediaSingle","attrs":{"layout":"center"},
20022            "content":[{"type":"media","attrs":{"type":"file","id":"img-b","collection":"c2","height":30,"width":40}}]
20023          }]}
20024        ]}]}"#;
20025        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20026        let md = adf_to_markdown(&doc).unwrap();
20027        let rt = markdown_to_adf(&md).unwrap();
20028
20029        let items = rt.content[0].content.as_ref().unwrap();
20030        assert_eq!(items.len(), 2);
20031        for (i, expected_id) in [("img-a", "c1"), ("img-b", "c2")].iter().enumerate() {
20032            let ms = &items[i].content.as_ref().unwrap()[0];
20033            assert_eq!(ms.node_type, "mediaSingle");
20034            let m_attrs = ms.content.as_ref().unwrap()[0].attrs.as_ref().unwrap();
20035            assert_eq!(m_attrs["id"], expected_id.0);
20036            assert_eq!(m_attrs["collection"], expected_id.1);
20037        }
20038    }
20039
20040    #[test]
20041    fn issue_430_jfm_to_adf_media_in_bullet_item() {
20042        // Parse JFM directly: image syntax on the first line of a bullet item
20043        // must produce mediaSingle, not a paragraph with corrupted text.
20044        let md = "- ![](){type=file id=test-id collection=col-1 height=100 width=200}\n";
20045        let doc = markdown_to_adf(md).unwrap();
20046
20047        let list = &doc.content[0];
20048        assert_eq!(list.node_type, "bulletList");
20049        let item = &list.content.as_ref().unwrap()[0];
20050        let ms = &item.content.as_ref().unwrap()[0];
20051        assert_eq!(
20052            ms.node_type, "mediaSingle",
20053            "expected mediaSingle, got {}",
20054            ms.node_type
20055        );
20056        let media = &ms.content.as_ref().unwrap()[0];
20057        assert_eq!(media.node_type, "media");
20058        let m_attrs = media.attrs.as_ref().unwrap();
20059        assert_eq!(m_attrs["type"], "file");
20060        assert_eq!(m_attrs["id"], "test-id");
20061    }
20062
20063    #[test]
20064    fn issue_430_jfm_to_adf_media_in_ordered_item() {
20065        // Parse JFM directly: image syntax on the first line of an ordered list item.
20066        let md = "1. ![alt text](https://example.com/photo.jpg)\n";
20067        let doc = markdown_to_adf(md).unwrap();
20068
20069        let list = &doc.content[0];
20070        assert_eq!(list.node_type, "orderedList");
20071        let item = &list.content.as_ref().unwrap()[0];
20072        let ms = &item.content.as_ref().unwrap()[0];
20073        assert_eq!(
20074            ms.node_type, "mediaSingle",
20075            "expected mediaSingle, got {}",
20076            ms.node_type
20077        );
20078    }
20079
20080    #[test]
20081    fn issue_430_media_then_paragraph_in_bullet_list_roundtrip() {
20082        // listItem with mediaSingle as first child followed by a paragraph.
20083        // Exercises the sub_lines non-empty path when first_node is mediaSingle.
20084        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20085          {"type":"listItem","content":[
20086            {"type":"mediaSingle","attrs":{"layout":"center"},
20087             "content":[{"type":"media","attrs":{"type":"file","id":"img-first","collection":"col-1","height":50,"width":100}}]},
20088            {"type":"paragraph","content":[{"type":"text","text":"Caption below"}]}
20089          ]}
20090        ]}]}"#;
20091        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20092        let md = adf_to_markdown(&doc).unwrap();
20093        let rt = markdown_to_adf(&md).unwrap();
20094
20095        let item = &rt.content[0].content.as_ref().unwrap()[0];
20096        let children = item.content.as_ref().unwrap();
20097        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20098        assert_eq!(children[0].node_type, "mediaSingle");
20099        let media = &children[0].content.as_ref().unwrap()[0];
20100        assert_eq!(media.attrs.as_ref().unwrap()["id"], "img-first");
20101        assert_eq!(children[1].node_type, "paragraph");
20102    }
20103
20104    #[test]
20105    fn issue_430_media_then_paragraph_in_ordered_list_roundtrip() {
20106        // Same as above but for ordered lists.
20107        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20108          {"type":"listItem","content":[
20109            {"type":"mediaSingle","attrs":{"layout":"center"},
20110             "content":[{"type":"media","attrs":{"type":"file","id":"img-ord","collection":"col-2","height":60,"width":120}}]},
20111            {"type":"paragraph","content":[{"type":"text","text":"Description"}]}
20112          ]}
20113        ]}]}"#;
20114        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20115        let md = adf_to_markdown(&doc).unwrap();
20116        let rt = markdown_to_adf(&md).unwrap();
20117
20118        let item = &rt.content[0].content.as_ref().unwrap()[0];
20119        let children = item.content.as_ref().unwrap();
20120        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20121        assert_eq!(children[0].node_type, "mediaSingle");
20122        assert_eq!(children[1].node_type, "paragraph");
20123    }
20124
20125    #[test]
20126    fn issue_430_external_media_with_width_type_roundtrip() {
20127        // External image with widthType attr must survive round-trip.
20128        let adf_json = r#"{"version":1,"type":"doc","content":[{
20129          "type":"mediaSingle",
20130          "attrs":{"layout":"wide","width":800,"widthType":"pixel"},
20131          "content":[{
20132            "type":"media",
20133            "attrs":{"type":"external","url":"https://example.com/photo.png","alt":"wide photo"}
20134          }]
20135        }]}"#;
20136        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20137        let md = adf_to_markdown(&doc).unwrap();
20138        assert!(
20139            md.contains("widthType=pixel"),
20140            "expected widthType=pixel in markdown, got: {md}"
20141        );
20142        let rt = markdown_to_adf(&md).unwrap();
20143        let ms = &rt.content[0];
20144        assert_eq!(ms.node_type, "mediaSingle");
20145        let ms_attrs = ms.attrs.as_ref().unwrap();
20146        assert_eq!(ms_attrs["widthType"], "pixel");
20147        assert_eq!(ms_attrs["width"], 800);
20148        assert_eq!(ms_attrs["layout"], "wide");
20149    }
20150
20151    // ── Issue #490: mediaSingle after hardBreak in listItem ─────
20152
20153    #[test]
20154    fn issue_490_paragraph_with_hardbreak_then_media_single_roundtrip() {
20155        // Reproducer from issue #490: paragraph with trailing hardBreak
20156        // followed by mediaSingle inside a listItem.
20157        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20158          {"type":"listItem","content":[
20159            {"type":"paragraph","content":[
20160              {"type":"text","text":"Item with image:"},
20161              {"type":"hardBreak"}
20162            ]},
20163            {"type":"mediaSingle","attrs":{"layout":"center","width":400,"widthType":"pixel"},
20164             "content":[{"type":"media","attrs":{
20165               "id":"aabbccdd-1234-5678-abcd-aabbccdd1234",
20166               "type":"file",
20167               "collection":"contentId-123456",
20168               "width":800,
20169               "height":600
20170             }}]}
20171          ]}
20172        ]}]}"#;
20173        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20174        let md = adf_to_markdown(&doc).unwrap();
20175        let rt = markdown_to_adf(&md).unwrap();
20176
20177        let item = &rt.content[0].content.as_ref().unwrap()[0];
20178        let children = item.content.as_ref().unwrap();
20179        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20180        assert_eq!(children[0].node_type, "paragraph");
20181        assert_eq!(
20182            children[1].node_type, "mediaSingle",
20183            "expected mediaSingle, got {:?}",
20184            children[1].node_type
20185        );
20186        let media = &children[1].content.as_ref().unwrap()[0];
20187        let m_attrs = media.attrs.as_ref().unwrap();
20188        assert_eq!(m_attrs["id"], "aabbccdd-1234-5678-abcd-aabbccdd1234");
20189        assert_eq!(m_attrs["collection"], "contentId-123456");
20190        assert_eq!(m_attrs["height"], 600);
20191        assert_eq!(m_attrs["width"], 800);
20192    }
20193
20194    #[test]
20195    fn issue_490_paragraph_with_hardbreak_then_media_single_ordered_list() {
20196        // Same scenario but in an ordered list.
20197        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20198          {"type":"listItem","content":[
20199            {"type":"paragraph","content":[
20200              {"type":"text","text":"Step with screenshot:"},
20201              {"type":"hardBreak"}
20202            ]},
20203            {"type":"mediaSingle","attrs":{"layout":"center"},
20204             "content":[{"type":"media","attrs":{
20205               "id":"ord-media-id","type":"file","collection":"col-ord","width":640,"height":480
20206             }}]}
20207          ]}
20208        ]}]}"#;
20209        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20210        let md = adf_to_markdown(&doc).unwrap();
20211        let rt = markdown_to_adf(&md).unwrap();
20212
20213        let item = &rt.content[0].content.as_ref().unwrap()[0];
20214        let children = item.content.as_ref().unwrap();
20215        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20216        assert_eq!(children[0].node_type, "paragraph");
20217        assert_eq!(children[1].node_type, "mediaSingle");
20218        let media = &children[1].content.as_ref().unwrap()[0];
20219        assert_eq!(media.attrs.as_ref().unwrap()["id"], "ord-media-id");
20220    }
20221
20222    #[test]
20223    fn issue_490_hardbreak_continuation_does_not_swallow_media_line() {
20224        // Directly tests that collect_hardbreak_continuations stops before
20225        // an indented mediaSingle line.
20226        let md = "- Item with image:\\\n  ![](){type=file id=test-490 collection=col height=100 width=200}\n";
20227        let doc = markdown_to_adf(md).unwrap();
20228
20229        let item = &doc.content[0].content.as_ref().unwrap()[0];
20230        let children = item.content.as_ref().unwrap();
20231        assert_eq!(children.len(), 2, "expected 2 children in listItem");
20232        assert_eq!(children[0].node_type, "paragraph");
20233        assert_eq!(
20234            children[1].node_type, "mediaSingle",
20235            "expected mediaSingle as second child, got {:?}",
20236            children[1].node_type
20237        );
20238        let media = &children[1].content.as_ref().unwrap()[0];
20239        assert_eq!(media.attrs.as_ref().unwrap()["id"], "test-490");
20240    }
20241
20242    #[test]
20243    fn issue_490_hardbreak_continuation_still_works_for_text() {
20244        // Ensure regular hardBreak continuations still work after the fix.
20245        let md = "- first line\\\n  second line\n";
20246        let doc = markdown_to_adf(md).unwrap();
20247
20248        let item = &doc.content[0].content.as_ref().unwrap()[0];
20249        let children = item.content.as_ref().unwrap();
20250        assert_eq!(
20251            children.len(),
20252            1,
20253            "expected 1 child (paragraph) in listItem"
20254        );
20255        assert_eq!(children[0].node_type, "paragraph");
20256        let inlines = children[0].content.as_ref().unwrap();
20257        // Should contain: text("first line"), hardBreak, text("second line")
20258        assert_eq!(inlines.len(), 3);
20259        assert_eq!(inlines[0].node_type, "text");
20260        assert_eq!(inlines[1].node_type, "hardBreak");
20261        assert_eq!(inlines[2].node_type, "text");
20262    }
20263
20264    #[test]
20265    fn issue_490_external_media_after_hardbreak_roundtrip() {
20266        // External image (URL-based) after a paragraph with hardBreak.
20267        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20268          {"type":"listItem","content":[
20269            {"type":"paragraph","content":[
20270              {"type":"text","text":"See image:"},
20271              {"type":"hardBreak"}
20272            ]},
20273            {"type":"mediaSingle","attrs":{"layout":"center"},
20274             "content":[{"type":"media","attrs":{
20275               "type":"external","url":"https://example.com/photo.png","alt":"photo"
20276             }}]}
20277          ]}
20278        ]}]}"#;
20279        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20280        let md = adf_to_markdown(&doc).unwrap();
20281        let rt = markdown_to_adf(&md).unwrap();
20282
20283        let item = &rt.content[0].content.as_ref().unwrap()[0];
20284        let children = item.content.as_ref().unwrap();
20285        assert_eq!(children.len(), 2);
20286        assert_eq!(children[0].node_type, "paragraph");
20287        assert_eq!(children[1].node_type, "mediaSingle");
20288        let media = &children[1].content.as_ref().unwrap()[0];
20289        let m_attrs = media.attrs.as_ref().unwrap();
20290        assert_eq!(m_attrs["url"], "https://example.com/photo.png");
20291    }
20292
20293    #[test]
20294    fn issue_490_multiple_hardbreaks_then_media_single() {
20295        // Paragraph with multiple hardBreaks, then mediaSingle.
20296        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20297          {"type":"listItem","content":[
20298            {"type":"paragraph","content":[
20299              {"type":"text","text":"line one"},
20300              {"type":"hardBreak"},
20301              {"type":"text","text":"line two"},
20302              {"type":"hardBreak"}
20303            ]},
20304            {"type":"mediaSingle","attrs":{"layout":"center"},
20305             "content":[{"type":"media","attrs":{
20306               "type":"file","id":"multi-hb","collection":"col-m","width":320,"height":240
20307             }}]}
20308          ]}
20309        ]}]}"#;
20310        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20311        let md = adf_to_markdown(&doc).unwrap();
20312        let rt = markdown_to_adf(&md).unwrap();
20313
20314        let item = &rt.content[0].content.as_ref().unwrap()[0];
20315        let children = item.content.as_ref().unwrap();
20316        assert_eq!(children.len(), 2, "expected paragraph + mediaSingle");
20317        assert_eq!(children[0].node_type, "paragraph");
20318        assert_eq!(children[1].node_type, "mediaSingle");
20319        let media = &children[1].content.as_ref().unwrap()[0];
20320        assert_eq!(media.attrs.as_ref().unwrap()["id"], "multi-hb");
20321    }
20322
20323    // ── Issue #525: listItem localId dropped when content includes mediaSingle ──
20324
20325    #[test]
20326    fn issue_525_listitem_localid_with_mediasingle_roundtrip() {
20327        // Exact reproducer from issue #525: listItem with UUID localId whose
20328        // content includes mediaSingle + paragraph + nested bulletList.
20329        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"}]}]}]}]}]}]}"#;
20330        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20331        let md = adf_to_markdown(&doc).unwrap();
20332        let rt = markdown_to_adf(&md).unwrap();
20333
20334        let list = &rt.content[0];
20335        assert_eq!(list.node_type, "bulletList");
20336        let item = &list.content.as_ref().unwrap()[0];
20337        // The localId must be preserved on the listItem.
20338        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20339        assert_eq!(
20340            item_attrs["localId"], "aabbccdd-1234-5678-abcd-000000000001",
20341            "listItem localId must survive round-trip"
20342        );
20343        let children = item.content.as_ref().unwrap();
20344        assert_eq!(
20345            children.len(),
20346            3,
20347            "expected mediaSingle + paragraph + bulletList"
20348        );
20349        assert_eq!(children[0].node_type, "mediaSingle");
20350        assert_eq!(children[1].node_type, "paragraph");
20351        assert_eq!(children[2].node_type, "bulletList");
20352    }
20353
20354    #[test]
20355    fn issue_525_listitem_localid_with_mediasingle_only() {
20356        // Minimal case: listItem with localId whose sole child is mediaSingle.
20357        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20358          {"type":"listItem","attrs":{"localId":"li-media-only"},"content":[
20359            {"type":"mediaSingle","attrs":{"layout":"center"},
20360             "content":[{"type":"media","attrs":{"type":"file","id":"m-001","collection":"c1","height":50,"width":100}}]}
20361          ]}
20362        ]}]}"#;
20363        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20364        let md = adf_to_markdown(&doc).unwrap();
20365        let rt = markdown_to_adf(&md).unwrap();
20366
20367        let item = &rt.content[0].content.as_ref().unwrap()[0];
20368        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20369        assert_eq!(
20370            item_attrs["localId"], "li-media-only",
20371            "listItem localId must survive when sole child is mediaSingle"
20372        );
20373        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20374    }
20375
20376    #[test]
20377    fn issue_525_listitem_localid_with_external_media() {
20378        // External image (URL-based) as first child with listItem localId.
20379        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20380          {"type":"listItem","attrs":{"localId":"li-ext-media"},"content":[
20381            {"type":"mediaSingle","attrs":{"layout":"center"},
20382             "content":[{"type":"media","attrs":{"type":"external","url":"https://example.com/img.png","alt":"photo"}}]}
20383          ]}
20384        ]}]}"#;
20385        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20386        let md = adf_to_markdown(&doc).unwrap();
20387        let rt = markdown_to_adf(&md).unwrap();
20388
20389        let item = &rt.content[0].content.as_ref().unwrap()[0];
20390        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20391        assert_eq!(
20392            item_attrs["localId"], "li-ext-media",
20393            "listItem localId must survive with external mediaSingle"
20394        );
20395    }
20396
20397    #[test]
20398    fn issue_525_listitem_localid_with_mediasingle_in_ordered_list() {
20399        // Same bug in an ordered list.
20400        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"orderedList","attrs":{"order":1},"content":[
20401          {"type":"listItem","attrs":{"localId":"li-ord-media"},"content":[
20402            {"type":"mediaSingle","attrs":{"layout":"center","width":200,"widthType":"pixel"},
20403             "content":[{"type":"media","attrs":{"type":"file","id":"ord-m-001","collection":"col-ord","height":80,"width":160}}]},
20404            {"type":"paragraph","content":[{"type":"text","text":"ordered item text"}]}
20405          ]}
20406        ]}]}"#;
20407        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20408        let md = adf_to_markdown(&doc).unwrap();
20409        let rt = markdown_to_adf(&md).unwrap();
20410
20411        let item = &rt.content[0].content.as_ref().unwrap()[0];
20412        let item_attrs = item.attrs.as_ref().expect("listItem attrs must be present");
20413        assert_eq!(
20414            item_attrs["localId"], "li-ord-media",
20415            "listItem localId must survive in ordered list with mediaSingle"
20416        );
20417        let children = item.content.as_ref().unwrap();
20418        assert_eq!(children[0].node_type, "mediaSingle");
20419        assert_eq!(children[1].node_type, "paragraph");
20420    }
20421
20422    #[test]
20423    fn issue_525_jfm_localid_on_mediasingle_line_parses_correctly() {
20424        // Verify JFM→ADF: trailing {localId=...} on a mediaSingle line
20425        // is assigned to the listItem, not the media node.
20426        let md = "- ![](){type=file id=test-525 collection=col height=100 width=200 mediaWidth=100 widthType=pixel} {localId=li-jfm-525}\n";
20427        let doc = markdown_to_adf(md).unwrap();
20428
20429        let item = &doc.content[0].content.as_ref().unwrap()[0];
20430        let item_attrs = item
20431            .attrs
20432            .as_ref()
20433            .expect("listItem attrs must be present from JFM");
20434        assert_eq!(item_attrs["localId"], "li-jfm-525");
20435        assert_eq!(item.content.as_ref().unwrap()[0].node_type, "mediaSingle");
20436    }
20437
20438    #[test]
20439    fn issue_525_encoding_emits_localid_on_mediasingle_line() {
20440        // Verify the ADF→JFM encoding: localId appears on the same line
20441        // as the mediaSingle image syntax.
20442        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
20443          {"type":"listItem","attrs":{"localId":"li-emit-check"},"content":[
20444            {"type":"mediaSingle","attrs":{"layout":"center"},
20445             "content":[{"type":"media","attrs":{"type":"file","id":"m-emit","collection":"c-emit","height":10,"width":20}}]}
20446          ]}
20447        ]}]}"#;
20448        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20449        let md = adf_to_markdown(&doc).unwrap();
20450        assert!(
20451            md.contains("{localId=li-emit-check}"),
20452            "expected localId in JFM output, got: {md}"
20453        );
20454        // The localId must be on the same line as the image
20455        for line in md.lines() {
20456            if line.contains("![") {
20457                assert!(
20458                    line.contains("localId=li-emit-check"),
20459                    "localId must be on the same line as the image: {line}"
20460                );
20461            }
20462        }
20463    }
20464
20465    // ── Placeholder node tests ────────────────────────────────────
20466
20467    #[test]
20468    fn adf_placeholder_to_markdown() {
20469        let doc = AdfDocument {
20470            version: 1,
20471            doc_type: "doc".to_string(),
20472            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder(
20473                "Type something here",
20474            )])],
20475        };
20476        let md = adf_to_markdown(&doc).unwrap();
20477        assert!(
20478            md.contains(":placeholder[Type something here]"),
20479            "expected :placeholder directive, got: {md}"
20480        );
20481    }
20482
20483    #[test]
20484    fn markdown_placeholder_to_adf() {
20485        let doc = markdown_to_adf("Before :placeholder[Enter name] after").unwrap();
20486        let content = doc.content[0].content.as_ref().unwrap();
20487        assert_eq!(content[1].node_type, "placeholder");
20488        let attrs = content[1].attrs.as_ref().unwrap();
20489        assert_eq!(attrs["text"], "Enter name");
20490    }
20491
20492    #[test]
20493    fn placeholder_round_trip() {
20494        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"placeholder","attrs":{"text":"Type something here"}}]}]}"#;
20495        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20496        let md = adf_to_markdown(&doc).unwrap();
20497        let rt = markdown_to_adf(&md).unwrap();
20498        let content = rt.content[0].content.as_ref().unwrap();
20499        assert_eq!(content.len(), 1);
20500        assert_eq!(content[0].node_type, "placeholder");
20501        let attrs = content[0].attrs.as_ref().unwrap();
20502        assert_eq!(attrs["text"], "Type something here");
20503    }
20504
20505    #[test]
20506    fn placeholder_empty_text() {
20507        let doc = AdfDocument {
20508            version: 1,
20509            doc_type: "doc".to_string(),
20510            content: vec![AdfNode::paragraph(vec![AdfNode::placeholder("")])],
20511        };
20512        let md = adf_to_markdown(&doc).unwrap();
20513        assert!(
20514            md.contains(":placeholder[]"),
20515            "expected empty placeholder directive, got: {md}"
20516        );
20517        let rt = markdown_to_adf(&md).unwrap();
20518        let content = rt.content[0].content.as_ref().unwrap();
20519        assert_eq!(content[0].node_type, "placeholder");
20520        assert_eq!(content[0].attrs.as_ref().unwrap()["text"], "");
20521    }
20522
20523    #[test]
20524    fn placeholder_with_surrounding_text() {
20525        let md = "Click :placeholder[here] to continue\n";
20526        let doc = markdown_to_adf(md).unwrap();
20527        let content = doc.content[0].content.as_ref().unwrap();
20528        assert_eq!(content[0].text.as_deref(), Some("Click "));
20529        assert_eq!(content[1].node_type, "placeholder");
20530        assert_eq!(content[1].attrs.as_ref().unwrap()["text"], "here");
20531        assert_eq!(content[2].text.as_deref(), Some(" to continue"));
20532    }
20533
20534    #[test]
20535    fn placeholder_missing_attrs() {
20536        // Placeholder node with no attrs should not panic
20537        let doc = AdfDocument {
20538            version: 1,
20539            doc_type: "doc".to_string(),
20540            content: vec![AdfNode::paragraph(vec![AdfNode {
20541                node_type: "placeholder".to_string(),
20542                attrs: None,
20543                content: None,
20544                text: None,
20545                marks: None,
20546                local_id: None,
20547                parameters: None,
20548            }])],
20549        };
20550        let md = adf_to_markdown(&doc).unwrap();
20551        // With no attrs, nothing is emitted for the placeholder
20552        assert!(!md.contains("placeholder"));
20553    }
20554
20555    // Issue #446: mention in table+list loses id and misplaces localId
20556    #[test]
20557    fn mention_in_table_bullet_list_preserves_id_and_local_id() {
20558        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":" "}]}]}]}]}]}]}]}"#;
20559        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20560        let md = adf_to_markdown(&doc).unwrap();
20561        let rt = markdown_to_adf(&md).unwrap();
20562
20563        // Navigate: doc → table → tableRow → tableCell → bulletList → listItem → paragraph
20564        let cell = &rt.content[0].content.as_ref().unwrap()[0]
20565            .content
20566            .as_ref()
20567            .unwrap()[0];
20568        let list = &cell.content.as_ref().unwrap()[0];
20569        let list_item = &list.content.as_ref().unwrap()[0];
20570
20571        // listItem must NOT have a localId attribute
20572        assert!(
20573            list_item
20574                .attrs
20575                .as_ref()
20576                .and_then(|a| a.get("localId"))
20577                .is_none(),
20578            "localId should stay on the mention, not the listItem"
20579        );
20580
20581        let para = &list_item.content.as_ref().unwrap()[0];
20582        let inlines = para.content.as_ref().unwrap();
20583
20584        // Should have: text("prefix text "), mention, text(" ")
20585        assert_eq!(inlines.len(), 3, "expected 3 inline nodes, got {inlines:?}");
20586
20587        assert_eq!(inlines[0].node_type, "text");
20588        assert_eq!(inlines[0].text.as_deref(), Some("prefix text "));
20589
20590        assert_eq!(inlines[1].node_type, "mention");
20591        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20592        assert_eq!(
20593            mention_attrs["id"], "aabbccdd11223344aabbccdd",
20594            "mention id must be preserved"
20595        );
20596        assert_eq!(
20597            mention_attrs["localId"], "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
20598            "mention localId must be preserved"
20599        );
20600        assert_eq!(mention_attrs["text"], "@Alice Example");
20601
20602        assert_eq!(inlines[2].node_type, "text");
20603        assert_eq!(inlines[2].text.as_deref(), Some(" "));
20604    }
20605
20606    #[test]
20607    fn mention_in_bullet_list_preserves_id_and_local_id() {
20608        // Same bug outside of a table context
20609        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":" "}]}]}]}]}"#;
20610        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20611        let md = adf_to_markdown(&doc).unwrap();
20612        let rt = markdown_to_adf(&md).unwrap();
20613
20614        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20615        assert!(
20616            list_item
20617                .attrs
20618                .as_ref()
20619                .and_then(|a| a.get("localId"))
20620                .is_none(),
20621            "localId should stay on the mention, not the listItem"
20622        );
20623
20624        let para = &list_item.content.as_ref().unwrap()[0];
20625        let inlines = para.content.as_ref().unwrap();
20626        assert_eq!(inlines[0].node_type, "mention");
20627        let mention_attrs = inlines[0].attrs.as_ref().unwrap();
20628        assert_eq!(mention_attrs["id"], "user123");
20629        assert_eq!(
20630            mention_attrs["localId"],
20631            "11111111-2222-3333-4444-555555555555"
20632        );
20633    }
20634
20635    #[test]
20636    fn mention_in_ordered_list_preserves_id_and_local_id() {
20637        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"}}]}]}]}]}"#;
20638        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20639        let md = adf_to_markdown(&doc).unwrap();
20640        let rt = markdown_to_adf(&md).unwrap();
20641
20642        let list_item = &rt.content[0].content.as_ref().unwrap()[0];
20643        assert!(
20644            list_item
20645                .attrs
20646                .as_ref()
20647                .and_then(|a| a.get("localId"))
20648                .is_none(),
20649            "localId should stay on the mention, not the listItem"
20650        );
20651
20652        let para = &list_item.content.as_ref().unwrap()[0];
20653        let inlines = para.content.as_ref().unwrap();
20654        assert_eq!(inlines[1].node_type, "mention");
20655        let mention_attrs = inlines[1].attrs.as_ref().unwrap();
20656        assert_eq!(mention_attrs["id"], "xyz");
20657        assert_eq!(mention_attrs["localId"], "aaaa-bbbb");
20658    }
20659
20660    #[test]
20661    fn list_item_own_local_id_with_mention_both_preserved() {
20662        // When a listItem has its own localId AND contains a mention with localId,
20663        // both should be preserved independently.
20664        let md = "- hello :mention[@Eve]{id=e1 localId=mention-lid} {localId=item-lid}\n";
20665        let doc = markdown_to_adf(md).unwrap();
20666        let list_item = &doc.content[0].content.as_ref().unwrap()[0];
20667
20668        // listItem should have its own localId
20669        let item_attrs = list_item.attrs.as_ref().unwrap();
20670        assert_eq!(item_attrs["localId"], "item-lid");
20671
20672        // mention should have its own localId
20673        let para = &list_item.content.as_ref().unwrap()[0];
20674        let inlines = para.content.as_ref().unwrap();
20675        let mention = inlines.iter().find(|n| n.node_type == "mention").unwrap();
20676        let mention_attrs = mention.attrs.as_ref().unwrap();
20677        assert_eq!(mention_attrs["id"], "e1");
20678        assert_eq!(mention_attrs["localId"], "mention-lid");
20679    }
20680
20681    #[test]
20682    fn extract_trailing_local_id_ignores_directive_attrs() {
20683        // Directly test the helper: a line ending with a directive's {…}
20684        // should NOT be treated as a trailing localId.
20685        let line = "text :mention[@X]{id=abc localId=uuid}";
20686        let (text, lid, plid) = extract_trailing_local_id(line);
20687        assert_eq!(text, line, "text should be unchanged");
20688        assert!(
20689            lid.is_none(),
20690            "should not extract localId from directive attrs"
20691        );
20692        assert!(plid.is_none());
20693    }
20694
20695    #[test]
20696    fn extract_trailing_local_id_matches_standalone_block() {
20697        // A standalone trailing {localId=…} separated by whitespace should still work.
20698        let line = "some text {localId=abc-123}";
20699        let (text, lid, plid) = extract_trailing_local_id(line);
20700        assert_eq!(text, "some text");
20701        assert_eq!(lid.as_deref(), Some("abc-123"));
20702        assert!(plid.is_none());
20703    }
20704
20705    // --- Issue #454: literal newline in text node inside listItem paragraph ---
20706
20707    #[test]
20708    fn newline_in_text_node_roundtrips_in_bullet_list() {
20709        // A text node containing a literal \n inside a bullet list item
20710        // must round-trip as a single text node with the embedded newline
20711        // preserved, not split into multiple paragraphs or hardBreak nodes.
20712        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"}]}]}]}]}"#;
20713        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20714        let md = adf_to_markdown(&doc).unwrap();
20715        let rt = markdown_to_adf(&md).unwrap();
20716
20717        // Should still be a single bulletList with one listItem
20718        assert_eq!(rt.content.len(), 1);
20719        let list = &rt.content[0];
20720        assert_eq!(list.node_type, "bulletList");
20721        let items = list.content.as_ref().unwrap();
20722        assert_eq!(items.len(), 1);
20723
20724        // The listItem should have exactly one paragraph child
20725        let item_content = items[0].content.as_ref().unwrap();
20726        assert_eq!(
20727            item_content.len(),
20728            1,
20729            "listItem should have exactly one paragraph"
20730        );
20731        assert_eq!(item_content[0].node_type, "paragraph");
20732
20733        // The embedded newline must survive as a single text node
20734        let inlines = item_content[0].content.as_ref().unwrap();
20735        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20736        assert_eq!(
20737            types,
20738            vec!["text", "hardBreak", "text"],
20739            "embedded newline should stay in a single text node, not produce extra hardBreaks"
20740        );
20741        assert_eq!(
20742            inlines[2].text.as_deref(),
20743            Some("first command\nsecond command")
20744        );
20745    }
20746
20747    #[test]
20748    fn newline_in_text_node_roundtrips_in_ordered_list() {
20749        // Same as above but in an ordered list.
20750        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"}]}]}]}]}"#;
20751        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20752        let md = adf_to_markdown(&doc).unwrap();
20753        let rt = markdown_to_adf(&md).unwrap();
20754
20755        let list = &rt.content[0];
20756        assert_eq!(list.node_type, "orderedList");
20757        let items = list.content.as_ref().unwrap();
20758        assert_eq!(items.len(), 1);
20759
20760        let item_content = items[0].content.as_ref().unwrap();
20761        assert_eq!(item_content.len(), 1);
20762        assert_eq!(item_content[0].node_type, "paragraph");
20763
20764        let inlines = item_content[0].content.as_ref().unwrap();
20765        assert_eq!(inlines.len(), 1);
20766        assert_eq!(inlines[0].node_type, "text");
20767        assert_eq!(inlines[0].text.as_deref(), Some("first\nsecond"));
20768    }
20769
20770    #[test]
20771    fn newline_in_text_node_roundtrips_in_paragraph() {
20772        // A text node with \n in a top-level paragraph should render as
20773        // escaped \n and round-trip back to a single text node.
20774        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello\nworld"}]}]}"#;
20775        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20776        let md = adf_to_markdown(&doc).unwrap();
20777        assert!(
20778            md.contains("hello\\nworld"),
20779            "newline in text node should render as escaped \\n: {md:?}"
20780        );
20781
20782        let rt = markdown_to_adf(&md).unwrap();
20783        let inlines = rt.content[0].content.as_ref().unwrap();
20784        assert_eq!(inlines.len(), 1);
20785        assert_eq!(inlines[0].text.as_deref(), Some("hello\nworld"));
20786    }
20787
20788    #[test]
20789    fn multiple_newlines_in_text_node_roundtrip() {
20790        // Multiple \n characters should each round-trip within the same text node.
20791        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"a\nb\nc"}]}]}]}]}"#;
20792        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20793        let md = adf_to_markdown(&doc).unwrap();
20794        let rt = markdown_to_adf(&md).unwrap();
20795
20796        let item_content = rt.content[0].content.as_ref().unwrap()[0]
20797            .content
20798            .as_ref()
20799            .unwrap();
20800        assert_eq!(item_content.len(), 1);
20801
20802        let inlines = item_content[0].content.as_ref().unwrap();
20803        assert_eq!(inlines.len(), 1);
20804        assert_eq!(inlines[0].text.as_deref(), Some("a\nb\nc"));
20805    }
20806
20807    #[test]
20808    fn newline_in_marked_text_node_roundtrips() {
20809        // A bold text node with \n should round-trip preserving both
20810        // the marks and the embedded newline.
20811        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold\ntext","marks":[{"type":"strong"}]}]}]}"#;
20812        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20813        let md = adf_to_markdown(&doc).unwrap();
20814        assert!(
20815            md.contains("**bold\\ntext**"),
20816            "bold text with embedded newline should stay in one marked run: {md:?}"
20817        );
20818
20819        let rt = markdown_to_adf(&md).unwrap();
20820        let inlines = rt.content[0].content.as_ref().unwrap();
20821        assert_eq!(inlines.len(), 1);
20822        assert_eq!(inlines[0].text.as_deref(), Some("bold\ntext"));
20823        assert!(inlines[0]
20824            .marks
20825            .as_ref()
20826            .unwrap()
20827            .iter()
20828            .any(|m| m.mark_type == "strong"));
20829    }
20830
20831    #[test]
20832    fn trailing_newline_in_text_node_roundtrips() {
20833        // A text node ending with \n should round-trip preserving the
20834        // trailing newline.
20835        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"trailing\n"}]}]}"#;
20836        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20837        let md = adf_to_markdown(&doc).unwrap();
20838        assert!(
20839            md.contains("trailing\\n"),
20840            "trailing newline should be escaped: {md:?}"
20841        );
20842
20843        let rt = markdown_to_adf(&md).unwrap();
20844        let inlines = rt.content[0].content.as_ref().unwrap();
20845        assert_eq!(inlines.len(), 1);
20846        assert_eq!(inlines[0].text.as_deref(), Some("trailing\n"));
20847    }
20848
20849    #[test]
20850    fn hardbreak_and_embedded_newline_are_distinct() {
20851        // A hardBreak node and an embedded \n in a text node must not be
20852        // conflated — each must round-trip to its original form.
20853        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"}]}]}"#;
20854        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20855        let md = adf_to_markdown(&doc).unwrap();
20856        let rt = markdown_to_adf(&md).unwrap();
20857
20858        let inlines = rt.content[0].content.as_ref().unwrap();
20859        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
20860        assert_eq!(
20861            types,
20862            vec!["text", "hardBreak", "text", "hardBreak", "text"]
20863        );
20864        assert_eq!(inlines[0].text.as_deref(), Some("before"));
20865        assert_eq!(inlines[2].text.as_deref(), Some("mid\ndle"));
20866        assert_eq!(inlines[4].text.as_deref(), Some("after"));
20867    }
20868
20869    // ---- Issue #472 tests ----
20870
20871    #[test]
20872    fn issue_472_bullet_list_trailing_hardbreak_roundtrips() {
20873        // Issue #472: trailing hardBreak at end of listItem paragraph must
20874        // not split the parent bulletList on round-trip.
20875        let adf_json = r#"{"version":1,"type":"doc","content":[
20876          {"type":"bulletList","content":[
20877            {"type":"listItem","content":[
20878              {"type":"paragraph","content":[
20879                {"type":"text","text":"First item"},
20880                {"type":"hardBreak"}
20881              ]}
20882            ]},
20883            {"type":"listItem","content":[
20884              {"type":"paragraph","content":[
20885                {"type":"text","text":"Second item"}
20886              ]}
20887            ]}
20888          ]}
20889        ]}"#;
20890        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20891        let md = adf_to_markdown(&doc).unwrap();
20892        let rt = markdown_to_adf(&md).unwrap();
20893
20894        // Must remain a single bulletList
20895        assert_eq!(
20896            rt.content.len(),
20897            1,
20898            "Should be 1 block (bulletList), got {}",
20899            rt.content.len()
20900        );
20901        assert_eq!(rt.content[0].node_type, "bulletList");
20902        let items = rt.content[0].content.as_ref().unwrap();
20903        assert_eq!(
20904            items.len(),
20905            2,
20906            "Should have 2 listItems, got {}",
20907            items.len()
20908        );
20909
20910        // First item: text + hardBreak (trailing)
20911        let p1 = items[0].content.as_ref().unwrap()[0]
20912            .content
20913            .as_ref()
20914            .unwrap();
20915        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20916        assert_eq!(types1, vec!["text", "hardBreak"]);
20917        assert_eq!(p1[0].text.as_deref(), Some("First item"));
20918
20919        // Second item: text only
20920        let p2 = items[1].content.as_ref().unwrap()[0]
20921            .content
20922            .as_ref()
20923            .unwrap();
20924        assert_eq!(p2[0].text.as_deref(), Some("Second item"));
20925    }
20926
20927    #[test]
20928    fn issue_472_ordered_list_trailing_hardbreak_roundtrips() {
20929        // Ordered list variant of issue #472.
20930        let adf_json = r#"{"version":1,"type":"doc","content":[
20931          {"type":"orderedList","attrs":{"order":1},"content":[
20932            {"type":"listItem","content":[
20933              {"type":"paragraph","content":[
20934                {"type":"text","text":"Alpha"},
20935                {"type":"hardBreak"}
20936              ]}
20937            ]},
20938            {"type":"listItem","content":[
20939              {"type":"paragraph","content":[
20940                {"type":"text","text":"Beta"}
20941              ]}
20942            ]}
20943          ]}
20944        ]}"#;
20945        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20946        let md = adf_to_markdown(&doc).unwrap();
20947        let rt = markdown_to_adf(&md).unwrap();
20948
20949        assert_eq!(rt.content.len(), 1);
20950        assert_eq!(rt.content[0].node_type, "orderedList");
20951        let items = rt.content[0].content.as_ref().unwrap();
20952        assert_eq!(items.len(), 2);
20953
20954        let p1 = items[0].content.as_ref().unwrap()[0]
20955            .content
20956            .as_ref()
20957            .unwrap();
20958        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
20959        assert_eq!(types1, vec!["text", "hardBreak"]);
20960        assert_eq!(p1[0].text.as_deref(), Some("Alpha"));
20961    }
20962
20963    #[test]
20964    fn issue_472_trailing_hardbreak_jfm_no_blank_line() {
20965        // The rendered JFM must not contain a blank line after the
20966        // trailing hardBreak — that would split the list.
20967        let adf_json = r#"{"version":1,"type":"doc","content":[
20968          {"type":"bulletList","content":[
20969            {"type":"listItem","content":[
20970              {"type":"paragraph","content":[
20971                {"type":"text","text":"Hello"},
20972                {"type":"hardBreak"}
20973              ]}
20974            ]},
20975            {"type":"listItem","content":[
20976              {"type":"paragraph","content":[
20977                {"type":"text","text":"World"}
20978              ]}
20979            ]}
20980          ]}
20981        ]}"#;
20982        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
20983        let md = adf_to_markdown(&doc).unwrap();
20984
20985        // Should produce "- Hello\\n- World\n" (no blank line between items).
20986        assert_eq!(md, "- Hello\\\n- World\n");
20987    }
20988
20989    #[test]
20990    fn issue_472_multiple_trailing_hardbreaks_roundtrip() {
20991        // Multiple trailing hardBreaks at the end of a listItem paragraph.
20992        let adf_json = r#"{"version":1,"type":"doc","content":[
20993          {"type":"bulletList","content":[
20994            {"type":"listItem","content":[
20995              {"type":"paragraph","content":[
20996                {"type":"text","text":"Item"},
20997                {"type":"hardBreak"},
20998                {"type":"hardBreak"}
20999              ]}
21000            ]},
21001            {"type":"listItem","content":[
21002              {"type":"paragraph","content":[
21003                {"type":"text","text":"Next"}
21004              ]}
21005            ]}
21006          ]}
21007        ]}"#;
21008        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21009        let md = adf_to_markdown(&doc).unwrap();
21010        let rt = markdown_to_adf(&md).unwrap();
21011
21012        // Must remain a single bulletList
21013        assert_eq!(rt.content.len(), 1);
21014        assert_eq!(rt.content[0].node_type, "bulletList");
21015        let items = rt.content[0].content.as_ref().unwrap();
21016        assert_eq!(items.len(), 2);
21017
21018        // First item should preserve both hardBreaks
21019        let p1 = items[0].content.as_ref().unwrap()[0]
21020            .content
21021            .as_ref()
21022            .unwrap();
21023        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21024        assert_eq!(types1, vec!["text", "hardBreak", "hardBreak"]);
21025    }
21026
21027    #[test]
21028    fn issue_472_hardbreak_mid_and_trailing_roundtrip() {
21029        // A hardBreak in the middle AND at the end of a listItem paragraph.
21030        let adf_json = r#"{"version":1,"type":"doc","content":[
21031          {"type":"bulletList","content":[
21032            {"type":"listItem","content":[
21033              {"type":"paragraph","content":[
21034                {"type":"text","text":"Line one"},
21035                {"type":"hardBreak"},
21036                {"type":"text","text":"Line two"},
21037                {"type":"hardBreak"}
21038              ]}
21039            ]},
21040            {"type":"listItem","content":[
21041              {"type":"paragraph","content":[
21042                {"type":"text","text":"Other item"}
21043              ]}
21044            ]}
21045          ]}
21046        ]}"#;
21047        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21048        let md = adf_to_markdown(&doc).unwrap();
21049        let rt = markdown_to_adf(&md).unwrap();
21050
21051        assert_eq!(rt.content.len(), 1);
21052        assert_eq!(rt.content[0].node_type, "bulletList");
21053        let items = rt.content[0].content.as_ref().unwrap();
21054        assert_eq!(items.len(), 2);
21055
21056        let p1 = items[0].content.as_ref().unwrap()[0]
21057            .content
21058            .as_ref()
21059            .unwrap();
21060        let types1: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21061        assert_eq!(types1, vec!["text", "hardBreak", "text", "hardBreak"]);
21062        assert_eq!(p1[0].text.as_deref(), Some("Line one"));
21063        assert_eq!(p1[2].text.as_deref(), Some("Line two"));
21064    }
21065
21066    #[test]
21067    fn issue_472_only_hardbreak_in_listitem_paragraph() {
21068        // Edge case: paragraph contains only a hardBreak, no text.
21069        let adf_json = r#"{"version":1,"type":"doc","content":[
21070          {"type":"bulletList","content":[
21071            {"type":"listItem","content":[
21072              {"type":"paragraph","content":[
21073                {"type":"hardBreak"}
21074              ]}
21075            ]},
21076            {"type":"listItem","content":[
21077              {"type":"paragraph","content":[
21078                {"type":"text","text":"After"}
21079              ]}
21080            ]}
21081          ]}
21082        ]}"#;
21083        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21084        let md = adf_to_markdown(&doc).unwrap();
21085        let rt = markdown_to_adf(&md).unwrap();
21086
21087        // Must remain a single bulletList with 2 items
21088        assert_eq!(rt.content.len(), 1);
21089        assert_eq!(rt.content[0].node_type, "bulletList");
21090        let items = rt.content[0].content.as_ref().unwrap();
21091        assert_eq!(items.len(), 2);
21092    }
21093
21094    #[test]
21095    fn issue_472_three_items_middle_has_trailing_hardbreak() {
21096        // Three-item list where only the middle item has a trailing hardBreak.
21097        let adf_json = r#"{"version":1,"type":"doc","content":[
21098          {"type":"bulletList","content":[
21099            {"type":"listItem","content":[
21100              {"type":"paragraph","content":[
21101                {"type":"text","text":"First"}
21102              ]}
21103            ]},
21104            {"type":"listItem","content":[
21105              {"type":"paragraph","content":[
21106                {"type":"text","text":"Second"},
21107                {"type":"hardBreak"}
21108              ]}
21109            ]},
21110            {"type":"listItem","content":[
21111              {"type":"paragraph","content":[
21112                {"type":"text","text":"Third"}
21113              ]}
21114            ]}
21115          ]}
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
21121        assert_eq!(rt.content.len(), 1);
21122        assert_eq!(rt.content[0].node_type, "bulletList");
21123        let items = rt.content[0].content.as_ref().unwrap();
21124        assert_eq!(items.len(), 3);
21125        assert_eq!(
21126            items[0].content.as_ref().unwrap()[0]
21127                .content
21128                .as_ref()
21129                .unwrap()[0]
21130                .text
21131                .as_deref(),
21132            Some("First")
21133        );
21134        assert_eq!(
21135            items[2].content.as_ref().unwrap()[0]
21136                .content
21137                .as_ref()
21138                .unwrap()[0]
21139                .text
21140                .as_deref(),
21141            Some("Third")
21142        );
21143    }
21144
21145    // ── Issue #494: trailing space-only text node after hardBreak ────
21146
21147    #[test]
21148    fn issue_494_space_after_hardbreak_roundtrip() {
21149        // The original reproducer from issue #494: a single space text
21150        // node following a hardBreak is silently dropped on round-trip.
21151        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21152          {"type":"text","text":"Some text"},
21153          {"type":"hardBreak"},
21154          {"type":"text","text":" "}
21155        ]}]}"#;
21156        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21157        let md = adf_to_markdown(&doc).unwrap();
21158        let rt = markdown_to_adf(&md).unwrap();
21159        let inlines = rt.content[0].content.as_ref().unwrap();
21160        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21161        assert_eq!(
21162            types,
21163            vec!["text", "hardBreak", "text"],
21164            "space-only text node after hardBreak should survive round-trip"
21165        );
21166        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21167    }
21168
21169    #[test]
21170    fn issue_494_multiple_spaces_after_hardbreak_roundtrip() {
21171        // Multiple spaces after hardBreak should also survive.
21172        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21173          {"type":"text","text":"Hello"},
21174          {"type":"hardBreak"},
21175          {"type":"text","text":"   "}
21176        ]}]}"#;
21177        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21178        let md = adf_to_markdown(&doc).unwrap();
21179        let rt = markdown_to_adf(&md).unwrap();
21180        let inlines = rt.content[0].content.as_ref().unwrap();
21181        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21182        assert_eq!(
21183            types,
21184            vec!["text", "hardBreak", "text"],
21185            "multi-space text node after hardBreak should survive round-trip"
21186        );
21187        assert_eq!(inlines[2].text.as_deref(), Some("   "));
21188    }
21189
21190    #[test]
21191    fn issue_494_space_then_text_after_hardbreak_roundtrip() {
21192        // Space followed by real text after hardBreak — the space should
21193        // be preserved as part of the text node.
21194        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21195          {"type":"text","text":"Before"},
21196          {"type":"hardBreak"},
21197          {"type":"text","text":" After"}
21198        ]}]}"#;
21199        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21200        let md = adf_to_markdown(&doc).unwrap();
21201        let rt = markdown_to_adf(&md).unwrap();
21202        let inlines = rt.content[0].content.as_ref().unwrap();
21203        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21204        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21205        assert_eq!(inlines[2].text.as_deref(), Some(" After"));
21206    }
21207
21208    #[test]
21209    fn issue_494_hardbreak_then_space_then_hardbreak_roundtrip() {
21210        // Space sandwiched between two hardBreaks.
21211        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21212          {"type":"text","text":"A"},
21213          {"type":"hardBreak"},
21214          {"type":"text","text":" "},
21215          {"type":"hardBreak"},
21216          {"type":"text","text":"B"}
21217        ]}]}"#;
21218        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21219        let md = adf_to_markdown(&doc).unwrap();
21220        let rt = markdown_to_adf(&md).unwrap();
21221        let inlines = rt.content[0].content.as_ref().unwrap();
21222        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21223        assert_eq!(
21224            types,
21225            vec!["text", "hardBreak", "text", "hardBreak", "text"],
21226            "space between two hardBreaks should survive round-trip"
21227        );
21228        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21229        assert_eq!(inlines[4].text.as_deref(), Some("B"));
21230    }
21231
21232    #[test]
21233    fn issue_494_trailing_space_hardbreak_style_not_confused() {
21234        // A plain paragraph break (blank line) should still work after
21235        // a line that does NOT end with a hardBreak marker.
21236        let md = "first paragraph\n\nsecond paragraph\n";
21237        let doc = markdown_to_adf(md).unwrap();
21238        assert_eq!(
21239            doc.content.len(),
21240            2,
21241            "blank line should still separate paragraphs"
21242        );
21243    }
21244
21245    #[test]
21246    fn issue_494_space_after_trailing_space_hardbreak_roundtrip() {
21247        // Same bug but with trailing-space style hardBreak (two spaces
21248        // before newline) instead of backslash style.
21249        let md = "line one  \n   \n";
21250        // The above is: "line one" + trailing-space hardBreak + continuation
21251        // line "   " (2-space indent + 1 space content).  The space-only
21252        // continuation should not be treated as a blank paragraph break.
21253        let doc = markdown_to_adf(md).unwrap();
21254        let inlines = doc.content[0].content.as_ref().unwrap();
21255        let has_text_after_break = inlines.iter().any(|n| {
21256            n.node_type == "text"
21257                && n.text
21258                    .as_deref()
21259                    .is_some_and(|t| t.trim().is_empty() && !t.is_empty())
21260        });
21261        assert!(
21262            has_text_after_break || inlines.len() >= 2,
21263            "space-only line after trailing-space hardBreak should be preserved"
21264        );
21265    }
21266
21267    #[test]
21268    fn issue_494_space_after_hardbreak_in_list_item_roundtrip() {
21269        // Exercises the same bug inside a list item context.
21270        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[
21271          {"type":"listItem","content":[{"type":"paragraph","content":[
21272            {"type":"text","text":"item"},
21273            {"type":"hardBreak"},
21274            {"type":"text","text":" "}
21275          ]}]}
21276        ]}]}"#;
21277        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21278        let md = adf_to_markdown(&doc).unwrap();
21279        let rt = markdown_to_adf(&md).unwrap();
21280        let list = &rt.content[0];
21281        let item = &list.content.as_ref().unwrap()[0];
21282        let para = &item.content.as_ref().unwrap()[0];
21283        let inlines = para.content.as_ref().unwrap();
21284        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21285        assert_eq!(
21286            types,
21287            vec!["text", "hardBreak", "text"],
21288            "space after hardBreak in list item should survive round-trip"
21289        );
21290        assert_eq!(inlines[2].text.as_deref(), Some(" "));
21291    }
21292
21293    // ── Issue #510: trailing spaces in text node should not become hardBreak ──
21294
21295    #[test]
21296    fn issue_510_trailing_double_space_paragraph_roundtrip() {
21297        // Two trailing spaces in a text node must survive round-trip without
21298        // being converted to a hardBreak or merging the next paragraph.
21299        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"}]}]}"#;
21300        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21301        let md = adf_to_markdown(&doc).unwrap();
21302        let rt = markdown_to_adf(&md).unwrap();
21303
21304        // Must produce two separate paragraphs
21305        assert_eq!(
21306            rt.content.len(),
21307            2,
21308            "should produce two paragraphs, got: {}",
21309            rt.content.len()
21310        );
21311        assert_eq!(rt.content[0].node_type, "paragraph");
21312        assert_eq!(rt.content[1].node_type, "paragraph");
21313
21314        // First paragraph text preserves trailing spaces
21315        let p1 = rt.content[0].content.as_ref().unwrap();
21316        assert_eq!(
21317            p1[0].text.as_deref(),
21318            Some("first paragraph with trailing spaces  "),
21319            "trailing spaces should be preserved in first paragraph"
21320        );
21321
21322        // Second paragraph is intact
21323        let p2 = rt.content[1].content.as_ref().unwrap();
21324        assert_eq!(p2[0].text.as_deref(), Some("second paragraph"));
21325
21326        // No hardBreak nodes should exist
21327        let all_types: Vec<&str> = p1.iter().map(|n| n.node_type.as_str()).collect();
21328        assert!(
21329            !all_types.contains(&"hardBreak"),
21330            "trailing spaces should not produce hardBreak, got: {all_types:?}"
21331        );
21332    }
21333
21334    #[test]
21335    fn issue_510_trailing_triple_space_roundtrip() {
21336        // Three trailing spaces also must not become a hardBreak.
21337        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"text   "}]},{"type":"paragraph","content":[{"type":"text","text":"next"}]}]}"#;
21338        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21339        let md = adf_to_markdown(&doc).unwrap();
21340        let rt = markdown_to_adf(&md).unwrap();
21341
21342        assert_eq!(rt.content.len(), 2, "should still be two paragraphs");
21343        let p1 = rt.content[0].content.as_ref().unwrap();
21344        assert_eq!(
21345            p1[0].text.as_deref(),
21346            Some("text   "),
21347            "three trailing spaces should be preserved"
21348        );
21349    }
21350
21351    #[test]
21352    fn issue_510_trailing_spaces_with_backslash_roundtrip() {
21353        // Text ending with backslash + trailing spaces: both must survive.
21354        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"end\\  "}]}]}"#;
21355        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21356        let md = adf_to_markdown(&doc).unwrap();
21357        let rt = markdown_to_adf(&md).unwrap();
21358        let p = rt.content[0].content.as_ref().unwrap();
21359        assert_eq!(
21360            p[0].text.as_deref(),
21361            Some("end\\  "),
21362            "backslash + trailing spaces should both survive"
21363        );
21364    }
21365
21366    #[test]
21367    fn issue_510_jfm_contains_escaped_trailing_space() {
21368        // Verify the serializer actually emits the backslash-space escape.
21369        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"hello  "}]}]}"#;
21370        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21371        let md = adf_to_markdown(&doc).unwrap();
21372        assert!(
21373            md.contains(r"\ "),
21374            "JFM should contain backslash-space escape for trailing spaces, got: {md:?}"
21375        );
21376        // Must NOT end with two plain spaces before newline
21377        for line in md.lines() {
21378            assert!(
21379                !line.ends_with("  "),
21380                "no JFM line should end with two plain spaces, got: {line:?}"
21381            );
21382        }
21383    }
21384
21385    #[test]
21386    fn issue_510_single_trailing_space_not_escaped() {
21387        // A single trailing space should NOT be escaped (not a hardBreak trigger).
21388        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"word "}]}]}"#;
21389        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21390        let md = adf_to_markdown(&doc).unwrap();
21391        assert!(
21392            !md.contains('\\'),
21393            "single trailing space should not be escaped, got: {md:?}"
21394        );
21395        let rt = markdown_to_adf(&md).unwrap();
21396        let p = rt.content[0].content.as_ref().unwrap();
21397        assert_eq!(p[0].text.as_deref(), Some("word "));
21398    }
21399
21400    #[test]
21401    fn issue_510_trailing_spaces_in_heading_roundtrip() {
21402        // Trailing double-spaces in a heading text node should also survive.
21403        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"heading  "}]}]}"#;
21404        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21405        let md = adf_to_markdown(&doc).unwrap();
21406        let rt = markdown_to_adf(&md).unwrap();
21407        let h = rt.content[0].content.as_ref().unwrap();
21408        assert_eq!(
21409            h[0].text.as_deref(),
21410            Some("heading  "),
21411            "trailing spaces in heading should be preserved"
21412        );
21413    }
21414
21415    #[test]
21416    fn issue_510_trailing_spaces_in_list_item_roundtrip() {
21417        // Trailing double-spaces in a bullet list item text node.
21418        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"bulletList","content":[{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"item  "}]}]}]}]}"#;
21419        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21420        let md = adf_to_markdown(&doc).unwrap();
21421        let rt = markdown_to_adf(&md).unwrap();
21422        let list = &rt.content[0];
21423        let item = &list.content.as_ref().unwrap()[0];
21424        let para = &item.content.as_ref().unwrap()[0];
21425        let inlines = para.content.as_ref().unwrap();
21426        assert_eq!(
21427            inlines[0].text.as_deref(),
21428            Some("item  "),
21429            "trailing spaces in list item should be preserved"
21430        );
21431    }
21432
21433    #[test]
21434    fn issue_510_trailing_spaces_with_bold_mark_roundtrip() {
21435        // Trailing spaces in a bold-marked text node: the closing **
21436        // comes after the spaces, so the line doesn't end with spaces.
21437        // But the escape should still be applied (and be harmless).
21438        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"bold  ","marks":[{"type":"strong"}]}]}]}"#;
21439        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21440        let md = adf_to_markdown(&doc).unwrap();
21441        let rt = markdown_to_adf(&md).unwrap();
21442        let p = rt.content[0].content.as_ref().unwrap();
21443        assert_eq!(
21444            p[0].text.as_deref(),
21445            Some("bold  "),
21446            "trailing spaces in bold text should be preserved"
21447        );
21448    }
21449
21450    #[test]
21451    fn issue_510_hardbreak_between_paragraphs_still_works() {
21452        // Actual hardBreak nodes must still round-trip correctly.
21453        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"line one"},{"type":"hardBreak"},{"type":"text","text":"line two"}]}]}"#;
21454        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21455        let md = adf_to_markdown(&doc).unwrap();
21456        let rt = markdown_to_adf(&md).unwrap();
21457        let inlines = rt.content[0].content.as_ref().unwrap();
21458        let types: Vec<&str> = inlines.iter().map(|n| n.node_type.as_str()).collect();
21459        assert_eq!(
21460            types,
21461            vec!["text", "hardBreak", "text"],
21462            "explicit hardBreak should still round-trip"
21463        );
21464    }
21465
21466    #[test]
21467    fn issue_510_all_spaces_text_node_roundtrip() {
21468        // A text node that is entirely spaces (2+) should survive.
21469        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"  "}]}]}"#;
21470        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21471        let md = adf_to_markdown(&doc).unwrap();
21472        let rt = markdown_to_adf(&md).unwrap();
21473        let p = rt.content[0].content.as_ref().unwrap();
21474        assert_eq!(
21475            p[0].text.as_deref(),
21476            Some("  "),
21477            "space-only text node should survive round-trip"
21478        );
21479    }
21480
21481    // ── Issue #522: listItem multi-paragraph merge ──────────────────────
21482
21483    #[test]
21484    fn issue_522_listitem_hardbreak_then_two_paragraphs_roundtrips() {
21485        // The exact reproducer from issue #522: first paragraph has
21486        // hardBreak nodes, followed by two sibling paragraphs.
21487        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"}]}]}]}]}"#;
21488        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21489        let md = adf_to_markdown(&doc).unwrap();
21490        let rt = markdown_to_adf(&md).unwrap();
21491
21492        let items = rt.content[0].content.as_ref().unwrap();
21493        assert_eq!(items.len(), 1);
21494        let children = items[0].content.as_ref().unwrap();
21495        assert_eq!(
21496            children.len(),
21497            3,
21498            "Expected 3 paragraphs in listItem, got {}",
21499            children.len()
21500        );
21501        assert_eq!(children[0].node_type, "paragraph");
21502        assert_eq!(children[1].node_type, "paragraph");
21503        assert_eq!(children[2].node_type, "paragraph");
21504
21505        // Verify the text content of each paragraph
21506        let text1 = children[1].content.as_ref().unwrap()[0]
21507            .text
21508            .as_deref()
21509            .unwrap();
21510        assert_eq!(text1, "second paragraph");
21511        let text2 = children[2].content.as_ref().unwrap()[0]
21512            .text
21513            .as_deref()
21514            .unwrap();
21515        assert_eq!(text2, "third paragraph");
21516    }
21517
21518    #[test]
21519    fn issue_522_ordered_list_hardbreak_then_paragraphs_roundtrips() {
21520        // Same scenario in an ordered list.
21521        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"}]}]}]}]}"#;
21522        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21523        let md = adf_to_markdown(&doc).unwrap();
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!(
21529            children.len(),
21530            3,
21531            "Expected 3 paragraphs in ordered listItem, got {}",
21532            children.len()
21533        );
21534        assert_eq!(children[1].node_type, "paragraph");
21535        assert_eq!(children[2].node_type, "paragraph");
21536        assert_eq!(
21537            children[1].content.as_ref().unwrap()[0]
21538                .text
21539                .as_deref()
21540                .unwrap(),
21541            "second para"
21542        );
21543        assert_eq!(
21544            children[2].content.as_ref().unwrap()[0]
21545                .text
21546                .as_deref()
21547                .unwrap(),
21548            "third para"
21549        );
21550    }
21551
21552    #[test]
21553    fn issue_522_two_paragraphs_without_hardbreak_roundtrips() {
21554        // Two paragraphs without hardBreak — should also remain separate.
21555        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"}]}]}]}]}"#;
21556        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21557        let md = adf_to_markdown(&doc).unwrap();
21558        let rt = markdown_to_adf(&md).unwrap();
21559
21560        let items = rt.content[0].content.as_ref().unwrap();
21561        let children = items[0].content.as_ref().unwrap();
21562        assert_eq!(
21563            children.len(),
21564            2,
21565            "Expected 2 paragraphs in listItem, got {}",
21566            children.len()
21567        );
21568        assert_eq!(children[0].node_type, "paragraph");
21569        assert_eq!(children[1].node_type, "paragraph");
21570    }
21571
21572    #[test]
21573    fn issue_522_paragraph_then_nested_list_no_spurious_blank() {
21574        // A paragraph followed by a nested list should NOT get a blank
21575        // separator (only paragraph-paragraph transitions need one).
21576        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"}]}]}]}]}]}]}"#;
21577        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21578        let md = adf_to_markdown(&doc).unwrap();
21579        // Should not contain a blank indented line between parent text and sub-list
21580        assert!(
21581            !md.contains("  \n  -"),
21582            "No blank separator between paragraph and nested list"
21583        );
21584        let rt = markdown_to_adf(&md).unwrap();
21585
21586        let items = rt.content[0].content.as_ref().unwrap();
21587        let children = items[0].content.as_ref().unwrap();
21588        assert_eq!(children.len(), 2);
21589        assert_eq!(children[0].node_type, "paragraph");
21590        assert_eq!(children[1].node_type, "bulletList");
21591    }
21592
21593    #[test]
21594    fn issue_522_three_paragraphs_no_hardbreak_roundtrips() {
21595        // Three plain paragraphs (no hardBreak) inside a single listItem.
21596        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"}]}]}]}]}"#;
21597        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21598        let md = adf_to_markdown(&doc).unwrap();
21599        let rt = markdown_to_adf(&md).unwrap();
21600
21601        let items = rt.content[0].content.as_ref().unwrap();
21602        let children = items[0].content.as_ref().unwrap();
21603        assert_eq!(
21604            children.len(),
21605            3,
21606            "Expected 3 paragraphs, got {}",
21607            children.len()
21608        );
21609        for (i, child) in children.iter().enumerate() {
21610            assert_eq!(
21611                child.node_type, "paragraph",
21612                "Child {i} should be a paragraph"
21613            );
21614        }
21615    }
21616
21617    #[test]
21618    fn issue_522_multiple_list_items_each_with_paragraphs() {
21619        // Multiple list items, each with multiple paragraphs.
21620        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"}]}]}]}]}"#;
21621        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21622        let md = adf_to_markdown(&doc).unwrap();
21623        let rt = markdown_to_adf(&md).unwrap();
21624
21625        let items = rt.content[0].content.as_ref().unwrap();
21626        assert_eq!(items.len(), 2, "Expected 2 list items");
21627
21628        let item1 = items[0].content.as_ref().unwrap();
21629        assert_eq!(item1.len(), 2, "Item 1 should have 2 paragraphs");
21630
21631        let item2 = items[1].content.as_ref().unwrap();
21632        assert_eq!(item2.len(), 2, "Item 2 should have 2 paragraphs");
21633        // Verify hardBreak is preserved in item2's first paragraph
21634        let item2_p1_inlines = item2[0].content.as_ref().unwrap();
21635        let types: Vec<&str> = item2_p1_inlines
21636            .iter()
21637            .map(|n| n.node_type.as_str())
21638            .collect();
21639        assert_eq!(types, vec!["text", "hardBreak", "text"]);
21640    }
21641
21642    #[test]
21643    fn issue_531_blockquote_hardbreak_then_two_paragraphs_roundtrips() {
21644        // The exact reproducer from issue #531: blockquote with first
21645        // paragraph containing hardBreak nodes, followed by two sibling
21646        // paragraphs.
21647        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"}]}]}]}"#;
21648        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21649        let md = adf_to_markdown(&doc).unwrap();
21650        let rt = markdown_to_adf(&md).unwrap();
21651
21652        let children = rt.content[0].content.as_ref().unwrap();
21653        assert_eq!(
21654            children.len(),
21655            3,
21656            "Expected 3 paragraphs in blockquote, got {}",
21657            children.len()
21658        );
21659        assert_eq!(children[0].node_type, "paragraph");
21660        assert_eq!(children[1].node_type, "paragraph");
21661        assert_eq!(children[2].node_type, "paragraph");
21662
21663        let text1 = children[1].content.as_ref().unwrap()[0]
21664            .text
21665            .as_deref()
21666            .unwrap();
21667        assert_eq!(text1, "second paragraph");
21668        let text2 = children[2].content.as_ref().unwrap()[0]
21669            .text
21670            .as_deref()
21671            .unwrap();
21672        assert_eq!(text2, "third paragraph");
21673    }
21674
21675    #[test]
21676    fn issue_531_blockquote_two_paragraphs_without_hardbreak_roundtrips() {
21677        // Two simple paragraphs inside a blockquote, no hardBreak.
21678        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"}]}]}]}"#;
21679        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21680        let md = adf_to_markdown(&doc).unwrap();
21681        let rt = markdown_to_adf(&md).unwrap();
21682
21683        let children = rt.content[0].content.as_ref().unwrap();
21684        assert_eq!(
21685            children.len(),
21686            2,
21687            "Expected 2 paragraphs in blockquote, got {}",
21688            children.len()
21689        );
21690        assert_eq!(children[0].node_type, "paragraph");
21691        assert_eq!(children[1].node_type, "paragraph");
21692    }
21693
21694    #[test]
21695    fn issue_531_blockquote_three_paragraphs_no_hardbreak_roundtrips() {
21696        // Three paragraphs, none with hardBreak.
21697        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"}]}]}]}"#;
21698        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21699        let md = adf_to_markdown(&doc).unwrap();
21700        let rt = markdown_to_adf(&md).unwrap();
21701
21702        let children = rt.content[0].content.as_ref().unwrap();
21703        assert_eq!(
21704            children.len(),
21705            3,
21706            "Expected 3 paragraphs in blockquote, got {}",
21707            children.len()
21708        );
21709        for child in children {
21710            assert_eq!(child.node_type, "paragraph");
21711        }
21712    }
21713
21714    #[test]
21715    fn issue_531_blockquote_paragraph_then_list_no_spurious_blank() {
21716        // A paragraph followed by a nested list inside a blockquote —
21717        // should NOT insert a blank separator line.
21718        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"}]}]}]}]}]}"#;
21719        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21720        let md = adf_to_markdown(&doc).unwrap();
21721        let rt = markdown_to_adf(&md).unwrap();
21722
21723        let children = rt.content[0].content.as_ref().unwrap();
21724        assert_eq!(children[0].node_type, "paragraph");
21725        assert_eq!(children[1].node_type, "bulletList");
21726    }
21727
21728    #[test]
21729    fn issue_531_blockquote_single_paragraph_unchanged() {
21730        // A single paragraph in a blockquote should remain unchanged.
21731        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"blockquote","content":[{"type":"paragraph","content":[{"type":"text","text":"solo"}]}]}]}"#;
21732        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21733        let md = adf_to_markdown(&doc).unwrap();
21734        let rt = markdown_to_adf(&md).unwrap();
21735
21736        let children = rt.content[0].content.as_ref().unwrap();
21737        assert_eq!(children.len(), 1);
21738        assert_eq!(children[0].node_type, "paragraph");
21739        let text = children[0].content.as_ref().unwrap()[0]
21740            .text
21741            .as_deref()
21742            .unwrap();
21743        assert_eq!(text, "solo");
21744    }
21745
21746    // ── Issue #554: marks combined with `code` or with each other ──────
21747
21748    /// Helper: roundtrip an ADF document and assert the marks on the first
21749    /// text node match `expected_marks` (in order).
21750    fn assert_roundtrip_marks(adf_json: &str, expected_marks: &[&str]) {
21751        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21752        let md = adf_to_markdown(&doc).unwrap();
21753        let rt = markdown_to_adf(&md).unwrap();
21754        let node = &rt.content[0].content.as_ref().unwrap()[0];
21755        let mark_types: Vec<&str> = node
21756            .marks
21757            .as_ref()
21758            .expect("should have marks")
21759            .iter()
21760            .map(|m| m.mark_type.as_str())
21761            .collect();
21762        assert_eq!(
21763            mark_types, expected_marks,
21764            "mark order mismatch for md={md}"
21765        );
21766    }
21767
21768    #[test]
21769    fn issue_554_code_and_text_color_preserved() {
21770        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21771          {"type":"text","text":"x","marks":[
21772            {"type":"textColor","attrs":{"color":"#008000"}},
21773            {"type":"code"}
21774          ]}
21775        ]}]}"##;
21776        assert_roundtrip_marks(adf_json, &["textColor", "code"]);
21777    }
21778
21779    #[test]
21780    fn issue_554_code_and_bg_color_preserved() {
21781        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21782          {"type":"text","text":"x","marks":[
21783            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21784            {"type":"code"}
21785          ]}
21786        ]}]}"##;
21787        assert_roundtrip_marks(adf_json, &["backgroundColor", "code"]);
21788    }
21789
21790    #[test]
21791    fn issue_554_code_and_subsup_preserved() {
21792        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21793          {"type":"text","text":"x","marks":[
21794            {"type":"subsup","attrs":{"type":"sub"}},
21795            {"type":"code"}
21796          ]}
21797        ]}]}"#;
21798        assert_roundtrip_marks(adf_json, &["subsup", "code"]);
21799    }
21800
21801    #[test]
21802    fn issue_554_code_and_underline_preserved() {
21803        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21804          {"type":"text","text":"x","marks":[
21805            {"type":"underline"},
21806            {"type":"code"}
21807          ]}
21808        ]}]}"#;
21809        assert_roundtrip_marks(adf_json, &["underline", "code"]);
21810    }
21811
21812    #[test]
21813    fn issue_554_code_textcolor_and_underline_preserved() {
21814        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21815          {"type":"text","text":"x","marks":[
21816            {"type":"textColor","attrs":{"color":"#008000"}},
21817            {"type":"underline"},
21818            {"type":"code"}
21819          ]}
21820        ]}]}"##;
21821        assert_roundtrip_marks(adf_json, &["textColor", "underline", "code"]);
21822    }
21823
21824    #[test]
21825    fn issue_554_textcolor_and_underline_preserved() {
21826        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21827          {"type":"text","text":"x","marks":[
21828            {"type":"textColor","attrs":{"color":"#008000"}},
21829            {"type":"underline"}
21830          ]}
21831        ]}]}"##;
21832        assert_roundtrip_marks(adf_json, &["textColor", "underline"]);
21833    }
21834
21835    #[test]
21836    fn issue_554_underline_and_textcolor_preserved_order_swapped() {
21837        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21838          {"type":"text","text":"x","marks":[
21839            {"type":"underline"},
21840            {"type":"textColor","attrs":{"color":"#008000"}}
21841          ]}
21842        ]}]}"##;
21843        // underline appears first, so it should be the OUTER wrapper.
21844        assert_roundtrip_marks(adf_json, &["underline", "textColor"]);
21845    }
21846
21847    #[test]
21848    fn issue_554_textcolor_and_annotation_preserved() {
21849        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21850          {"type":"text","text":"x","marks":[
21851            {"type":"textColor","attrs":{"color":"#008000"}},
21852            {"type":"annotation","attrs":{"id":"abc-123","annotationType":"inlineComment"}}
21853          ]}
21854        ]}]}"##;
21855        assert_roundtrip_marks(adf_json, &["textColor", "annotation"]);
21856    }
21857
21858    #[test]
21859    fn issue_554_bgcolor_and_underline_preserved() {
21860        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21861          {"type":"text","text":"x","marks":[
21862            {"type":"backgroundColor","attrs":{"color":"#FF0000"}},
21863            {"type":"underline"}
21864          ]}
21865        ]}]}"##;
21866        assert_roundtrip_marks(adf_json, &["backgroundColor", "underline"]);
21867    }
21868
21869    #[test]
21870    fn issue_554_subsup_and_underline_preserved() {
21871        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
21872          {"type":"text","text":"x","marks":[
21873            {"type":"subsup","attrs":{"type":"sub"}},
21874            {"type":"underline"}
21875          ]}
21876        ]}]}"#;
21877        assert_roundtrip_marks(adf_json, &["subsup", "underline"]);
21878    }
21879
21880    #[test]
21881    fn issue_554_exact_reproducer_full_match() {
21882        // The exact reproducer from issue #554. The byte-for-byte ADF JSON
21883        // must round-trip through `from-adf | to-adf` unchanged.
21884        let adf_json = r##"{
21885          "version": 1,
21886          "type": "doc",
21887          "content": [
21888            {
21889              "type": "paragraph",
21890              "content": [
21891                {"type":"text","text":"Status: ","marks":[{"type":"strong"}]},
21892                {"type":"text","text":"Approved","marks":[
21893                  {"type":"textColor","attrs":{"color":"#008000"}}
21894                ]},
21895                {"type":"text","text":" — ready to proceed"}
21896              ]
21897            }
21898          ]
21899        }"##;
21900        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21901        let md = adf_to_markdown(&doc).unwrap();
21902        assert!(
21903            md.contains(":span[Approved]{color=#008000}"),
21904            "JFM should contain green span: {md}"
21905        );
21906        let rt = markdown_to_adf(&md).unwrap();
21907        // Find the "Approved" text node and verify color is preserved.
21908        let approved = rt.content[0]
21909            .content
21910            .as_ref()
21911            .unwrap()
21912            .iter()
21913            .find(|n| n.text.as_deref() == Some("Approved"))
21914            .expect("Approved text node");
21915        let marks = approved.marks.as_ref().expect("should have marks");
21916        let color_mark = marks
21917            .iter()
21918            .find(|m| m.mark_type == "textColor")
21919            .expect("textColor mark must be preserved");
21920        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
21921    }
21922
21923    #[test]
21924    fn issue_554_textcolor_with_code_renders_span_around_code() {
21925        // Verify the rendered JFM uses `:span[`text`]{color=...}` — the
21926        // syntax suggested in the issue.
21927        let doc = AdfDocument {
21928            version: 1,
21929            doc_type: "doc".to_string(),
21930            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21931                "fn main",
21932                vec![
21933                    AdfMark::text_color("#008000"),
21934                    AdfMark {
21935                        mark_type: "code".to_string(),
21936                        attrs: None,
21937                    },
21938                ],
21939            )])],
21940        };
21941        let md = adf_to_markdown(&doc).unwrap();
21942        assert!(
21943            md.contains(":span[`fn main`]{color=#008000}"),
21944            "expected span-wrapped code, got: {md}"
21945        );
21946    }
21947
21948    #[test]
21949    fn issue_554_underline_with_code_renders_bracketed_around_code() {
21950        let doc = AdfDocument {
21951            version: 1,
21952            doc_type: "doc".to_string(),
21953            content: vec![AdfNode::paragraph(vec![AdfNode::text_with_marks(
21954                "fn main",
21955                vec![
21956                    AdfMark::underline(),
21957                    AdfMark {
21958                        mark_type: "code".to_string(),
21959                        attrs: None,
21960                    },
21961                ],
21962            )])],
21963        };
21964        let md = adf_to_markdown(&doc).unwrap();
21965        assert!(
21966            md.contains("[`fn main`]{underline}"),
21967            "expected bracketed-span around code, got: {md}"
21968        );
21969    }
21970
21971    // ── Issue #554 (re-opened): boundary-underscore destroys span directives ──
21972
21973    #[test]
21974    fn issue_554_underscore_adjacent_to_textcolor_span_roundtrip() {
21975        // Reproducer from the re-opened issue: a `_ ` plain-text node followed
21976        // by a textColor span whose text starts with `_` produced JFM that the
21977        // parser saw as an italic delimiter pair, destroying the span and
21978        // losing the textColor mark entirely.
21979        let adf_json = r##"{
21980          "version": 1,
21981          "type": "doc",
21982          "content": [
21983            {
21984              "type": "paragraph",
21985              "content": [
21986                {"type":"text","text":"_ "},
21987                {"type":"text","text":"_Action:*","marks":[
21988                  {"type":"textColor","attrs":{"color":"#008000"}}
21989                ]},
21990                {"type":"text","text":" Complete the setup process."}
21991              ]
21992            }
21993          ]
21994        }"##;
21995        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
21996        let md = adf_to_markdown(&doc).unwrap();
21997        // The leading `_` chars must be backslash-escaped so the parser
21998        // doesn't form a false italic pair across the span boundary.
21999        assert!(
22000            md.contains(r"\_ ") && md.contains(r":span[\_Action"),
22001            "underscores at node boundaries should be escaped: {md}"
22002        );
22003        let rt = markdown_to_adf(&md).unwrap();
22004        let para_content = rt.content[0].content.as_ref().unwrap();
22005        // Find the textColor-marked node.
22006        let colored = para_content
22007            .iter()
22008            .find(|n| {
22009                n.marks
22010                    .as_deref()
22011                    .is_some_and(|ms| ms.iter().any(|m| m.mark_type == "textColor"))
22012            })
22013            .expect("textColor node must be preserved");
22014        assert_eq!(colored.text.as_deref(), Some("_Action:*"));
22015        let color_mark = colored
22016            .marks
22017            .as_ref()
22018            .unwrap()
22019            .iter()
22020            .find(|m| m.mark_type == "textColor")
22021            .unwrap();
22022        assert_eq!(color_mark.attrs.as_ref().unwrap()["color"], "#008000");
22023        // Verify no spurious em mark crept in.
22024        for n in para_content {
22025            if let Some(ms) = n.marks.as_deref() {
22026                assert!(
22027                    !ms.iter().any(|m| m.mark_type == "em"),
22028                    "no em mark should appear, got marks {:?}",
22029                    ms.iter().map(|m| &m.mark_type).collect::<Vec<_>>()
22030                );
22031            }
22032        }
22033    }
22034
22035    #[test]
22036    fn issue_554_underscore_intraword_left_unescaped() {
22037        // Sanity check: ordinary intraword underscores like `do_something_useful`
22038        // should NOT be escaped — escaping would still round-trip correctly,
22039        // but produces noisy backslashes in the JFM output.
22040        let doc = AdfDocument {
22041            version: 1,
22042            doc_type: "doc".to_string(),
22043            content: vec![AdfNode::paragraph(vec![AdfNode::text(
22044                "call do_something_useful now",
22045            )])],
22046        };
22047        let md = adf_to_markdown(&doc).unwrap();
22048        assert!(
22049            md.contains("do_something_useful") && !md.contains(r"do\_something\_useful"),
22050            "intraword underscores should not be escaped: {md}"
22051        );
22052    }
22053
22054    #[test]
22055    fn issue_554_code_underline_then_textcolor_bracketed_outer() {
22056        // Mark order [underline, textColor, code] — bracketed-span outer,
22057        // span inner. Exercises wrap_with_attrs (true, true) !span_before.
22058        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22059          {"type":"text","text":"x","marks":[
22060            {"type":"underline"},
22061            {"type":"textColor","attrs":{"color":"#008000"}},
22062            {"type":"code"}
22063          ]}
22064        ]}]}"##;
22065        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22066        let md = adf_to_markdown(&doc).unwrap();
22067        // Bracketed-span should be the outermost wrapper.
22068        assert!(
22069            md.starts_with('[') && md.contains("underline}"),
22070            "bracketed-span should wrap the span, got: {md}"
22071        );
22072        let rt = markdown_to_adf(&md).unwrap();
22073        let node = &rt.content[0].content.as_ref().unwrap()[0];
22074        let mark_types: Vec<&str> = node
22075            .marks
22076            .as_ref()
22077            .unwrap()
22078            .iter()
22079            .map(|m| m.mark_type.as_str())
22080            .collect();
22081        assert_eq!(mark_types, vec!["underline", "textColor", "code"]);
22082    }
22083
22084    #[test]
22085    fn issue_554_textcolor_underline_link_all_preserved() {
22086        // Mark order [textColor, underline, link] — span outer, bracketed
22087        // wraps the link inside. Exercises the span-wraps-link-with-bracketed
22088        // branch.
22089        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22090          {"type":"text","text":"linked","marks":[
22091            {"type":"textColor","attrs":{"color":"#008000"}},
22092            {"type":"underline"},
22093            {"type":"link","attrs":{"href":"https://example.com"}}
22094          ]}
22095        ]}]}"##;
22096        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22097        let md = adf_to_markdown(&doc).unwrap();
22098        let rt = markdown_to_adf(&md).unwrap();
22099        let node = &rt.content[0].content.as_ref().unwrap()[0];
22100        let mark_types: Vec<&str> = node
22101            .marks
22102            .as_ref()
22103            .unwrap()
22104            .iter()
22105            .map(|m| m.mark_type.as_str())
22106            .collect();
22107        assert_eq!(mark_types, vec!["textColor", "underline", "link"]);
22108    }
22109
22110    #[test]
22111    fn issue_554_underline_textcolor_link_bracketed_outer_link_last() {
22112        // Mark order [underline, textColor, link] — bracketed-span outer of
22113        // both span and link. Exercises the bracketed-wraps-everything branch.
22114        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22115          {"type":"text","text":"linked","marks":[
22116            {"type":"underline"},
22117            {"type":"textColor","attrs":{"color":"#008000"}},
22118            {"type":"link","attrs":{"href":"https://example.com"}}
22119          ]}
22120        ]}]}"##;
22121        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22122        let md = adf_to_markdown(&doc).unwrap();
22123        let rt = markdown_to_adf(&md).unwrap();
22124        let node = &rt.content[0].content.as_ref().unwrap()[0];
22125        let mark_types: Vec<&str> = node
22126            .marks
22127            .as_ref()
22128            .unwrap()
22129            .iter()
22130            .map(|m| m.mark_type.as_str())
22131            .collect();
22132        assert_eq!(mark_types, vec!["underline", "textColor", "link"]);
22133    }
22134
22135    #[test]
22136    fn issue_554_link_underline_textcolor_link_outer() {
22137        // Mark order [link, underline, textColor] — link outermost, wraps a
22138        // bracketed-span that wraps the span. Exercises the link-wraps-
22139        // bracketed-wraps-span branch.
22140        let adf_json = r##"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22141          {"type":"text","text":"linked","marks":[
22142            {"type":"link","attrs":{"href":"https://example.com"}},
22143            {"type":"underline"},
22144            {"type":"textColor","attrs":{"color":"#008000"}}
22145          ]}
22146        ]}]}"##;
22147        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22148        let md = adf_to_markdown(&doc).unwrap();
22149        assert!(
22150            md.starts_with('[') && md.contains("](https://example.com)"),
22151            "link should be outermost, got: {md}"
22152        );
22153        let rt = markdown_to_adf(&md).unwrap();
22154        let node = &rt.content[0].content.as_ref().unwrap()[0];
22155        let mark_types: Vec<&str> = node
22156            .marks
22157            .as_ref()
22158            .unwrap()
22159            .iter()
22160            .map(|m| m.mark_type.as_str())
22161            .collect();
22162        assert_eq!(mark_types, vec!["link", "underline", "textColor"]);
22163    }
22164
22165    #[test]
22166    fn issue_554_trailing_underscore_then_leading_underscore_round_trip() {
22167        // Two adjacent text nodes where the first ends with `_` and the
22168        // second starts with `_` — without escaping, the JFM parser sees
22169        // an `_..._` pair spanning the boundary.
22170        let adf_json = r#"{"version":1,"type":"doc","content":[{"type":"paragraph","content":[
22171          {"type":"text","text":"end_"},
22172          {"type":"text","text":"_start"}
22173        ]}]}"#;
22174        let doc: AdfDocument = serde_json::from_str(adf_json).unwrap();
22175        let md = adf_to_markdown(&doc).unwrap();
22176        let rt = markdown_to_adf(&md).unwrap();
22177        // Reassemble all text in the paragraph.
22178        let combined: String = rt.content[0]
22179            .content
22180            .as_ref()
22181            .unwrap()
22182            .iter()
22183            .filter_map(|n| n.text.as_deref())
22184            .collect();
22185        assert_eq!(combined, "end__start");
22186        // No node should have an em mark.
22187        for n in rt.content[0].content.as_ref().unwrap() {
22188            if let Some(ms) = n.marks.as_deref() {
22189                assert!(!ms.iter().any(|m| m.mark_type == "em"));
22190            }
22191        }
22192    }
22193
22194    // ── Nesting depth limit (issue #1130) ──────────────────────────────
22195    //
22196    // Without MAX_NESTING_DEPTH each of these inputs recurses one stack
22197    // frame per nesting level, overflowing the stack — an uncatchable
22198    // abort.  The block-path tests assert a clean error; the inline-path
22199    // tests assert graceful degradation (no abort, valid document).
22200
22201    #[test]
22202    fn blockquote_nesting_beyond_cap_errors() {
22203        let md = format!("{}x", "> ".repeat(200));
22204        let err = markdown_to_adf(&md).unwrap_err();
22205        assert!(err.to_string().contains("maximum depth"));
22206    }
22207
22208    #[test]
22209    fn list_nesting_beyond_cap_errors() {
22210        let mut md = String::new();
22211        for i in 0..200 {
22212            md.push_str(&"  ".repeat(i));
22213            md.push_str("- x\n");
22214        }
22215        let err = markdown_to_adf(&md).unwrap_err();
22216        assert!(err.to_string().contains("maximum depth"));
22217    }
22218
22219    #[test]
22220    fn container_directive_nesting_beyond_cap_errors() {
22221        let mut md = String::new();
22222        for _ in 0..200 {
22223            md.push_str(":::panel{type=info}\n");
22224        }
22225        md.push_str("body\n");
22226        for _ in 0..200 {
22227            md.push_str(":::\n");
22228        }
22229        let err = markdown_to_adf(&md).unwrap_err();
22230        assert!(err.to_string().contains("maximum depth"));
22231    }
22232
22233    #[test]
22234    fn directive_table_cell_nesting_beyond_cap_errors() {
22235        let mut md = String::new();
22236        for _ in 0..200 {
22237            md.push_str("::::table\n:::tr\n:::td\n");
22238        }
22239        md.push_str("cell\n");
22240        for _ in 0..200 {
22241            md.push_str(":::\n:::\n::::\n");
22242        }
22243        let err = markdown_to_adf(&md).unwrap_err();
22244        assert!(err.to_string().contains("maximum depth"));
22245    }
22246
22247    #[test]
22248    fn layout_column_nesting_beyond_cap_errors() {
22249        let mut md = String::new();
22250        for _ in 0..200 {
22251            md.push_str("::::layout\n:::column{width=100}\n");
22252        }
22253        md.push_str("x\n");
22254        for _ in 0..200 {
22255            md.push_str(":::\n::::\n");
22256        }
22257        let err = markdown_to_adf(&md).unwrap_err();
22258        assert!(err.to_string().contains("maximum depth"));
22259    }
22260
22261    #[test]
22262    fn blockquote_nesting_at_cap_boundary() {
22263        // A parser at depth d handles the (d+1)-th `>` level, so exactly
22264        // MAX_NESTING_DEPTH levels succeed and one more errors.
22265        let at_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH));
22266        assert!(markdown_to_adf(&at_cap).is_ok());
22267        let past_cap = format!("{}x", "> ".repeat(MAX_NESTING_DEPTH + 1));
22268        let err = markdown_to_adf(&past_cap).unwrap_err();
22269        assert!(err.to_string().contains("maximum depth"));
22270    }
22271
22272    #[test]
22273    fn list_nesting_at_cap_succeeds() {
22274        // The nested-list path has the largest debug-build stack frames, so
22275        // an at-cap success here proves the cap leaves stack headroom on a
22276        // default 2 MiB test thread (the canary for MAX_NESTING_DEPTH).
22277        let mut md = String::new();
22278        for i in 0..MAX_NESTING_DEPTH {
22279            md.push_str(&"  ".repeat(i));
22280            md.push_str("- x\n");
22281        }
22282        assert!(markdown_to_adf(&md).is_ok());
22283    }
22284
22285    #[test]
22286    fn deep_but_reasonable_nesting_succeeds() {
22287        let md = format!("{}deep", "> ".repeat(20));
22288        let doc = markdown_to_adf(&md).unwrap();
22289        // Walk down the 20 blockquote levels to the paragraph.
22290        let mut node = &doc.content[0];
22291        for _ in 0..20 {
22292            assert_eq!(node.node_type, "blockquote");
22293            node = &node.content.as_ref().unwrap()[0];
22294        }
22295        assert_eq!(node.node_type, "paragraph");
22296    }
22297
22298    #[test]
22299    fn deeply_nested_emphasis_degrades_without_overflow() {
22300        let md = format!("{}x{}", "*_".repeat(400), "_*".repeat(400));
22301        let doc = markdown_to_adf(&md).unwrap();
22302        assert_eq!(doc.content[0].node_type, "paragraph");
22303    }
22304
22305    #[test]
22306    fn deeply_nested_links_degrade_without_overflow() {
22307        let mut md = String::from("x");
22308        for _ in 0..400 {
22309            md = format!("[{md}](https://example.com)");
22310        }
22311        let doc = markdown_to_adf(&md).unwrap();
22312        assert_eq!(doc.content[0].node_type, "paragraph");
22313    }
22314
22315    #[test]
22316    fn deeply_nested_bracketed_spans_degrade_without_overflow() {
22317        let mut md = String::from("x");
22318        for _ in 0..400 {
22319            md = format!("[{md}]{{underline}}");
22320        }
22321        let doc = markdown_to_adf(&md).unwrap();
22322        assert_eq!(doc.content[0].node_type, "paragraph");
22323    }
22324}